diff --git a/src/components/App/index.jsx b/src/components/App/index.jsx
index 5915c030a4..ee1afbbf11 100644
--- a/src/components/App/index.jsx
+++ b/src/components/App/index.jsx
@@ -23,10 +23,12 @@ import { SystemWideWarningBanner } from '../system-wide-banner';
import store from '../../data/store';
import { ROUTE_NAMES } from '../EnterpriseApp/data/constants';
+import { defaultQueryClientRetryHandler } from '../../utils';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
+ retry: defaultQueryClientRetryHandler,
// Specifying a longer `staleTime` of 20 seconds means queries will not refetch their data
// as often; mitigates making duplicate queries when within the `staleTime` window, instead
// relying on the cached data until the `staleTime` window has exceeded. This may be modified
diff --git a/src/components/learner-credit-management/BudgetDetailActivityTabContents.jsx b/src/components/learner-credit-management/BudgetDetailActivityTabContents.jsx
index 123b48f7e5..8c41d33eef 100644
--- a/src/components/learner-credit-management/BudgetDetailActivityTabContents.jsx
+++ b/src/components/learner-credit-management/BudgetDetailActivityTabContents.jsx
@@ -14,13 +14,16 @@ const BudgetDetailActivityTabContents = ({ enterpriseUUID, enterpriseFeatures })
const { data: subsidyAccessPolicy } = useSubsidyAccessPolicy(subsidyAccessPolicyId);
const {
isLoading: isBudgetActivityOverviewLoading,
+ isFetching: isBudgetActivityOverviewFetching,
data: budgetActivityOverview,
} = useBudgetDetailActivityOverview({
enterpriseUUID,
isTopDownAssignmentEnabled,
});
- if (isBudgetActivityOverviewLoading || !budgetActivityOverview) {
+ // // If the budget activity overview data is loading (either the initial request OR any
+ // // background re-fetching), show a skeleton.
+ if (isBudgetActivityOverviewLoading || isBudgetActivityOverviewFetching || !budgetActivityOverview) {
return (
<>
diff --git a/src/components/learner-credit-management/BudgetDetailPage.jsx b/src/components/learner-credit-management/BudgetDetailPage.jsx
index 1ad1ff56cc..3a7192e5e4 100644
--- a/src/components/learner-credit-management/BudgetDetailPage.jsx
+++ b/src/components/learner-credit-management/BudgetDetailPage.jsx
@@ -5,17 +5,20 @@ import { useBudgetId, useSubsidyAccessPolicy } from './data';
import BudgetDetailTabsAndRoutes from './BudgetDetailTabsAndRoutes';
import BudgetDetailPageWrapper from './BudgetDetailPageWrapper';
import BudgetDetailPageHeader from './BudgetDetailPageHeader';
+import NotFoundPage from '../NotFoundPage';
const BudgetDetailPage = () => {
const { subsidyAccessPolicyId } = useBudgetId();
const {
- isInitialLoading: isInitialLoadingSubsidyAccessPolicy,
data: subsidyAccessPolicy,
+ isInitialLoading: isSubsidyAccessPolicyInitialLoading,
+ isError: isSubsidyAccessPolicyError,
+ error,
} = useSubsidyAccessPolicy(subsidyAccessPolicyId);
- if (isInitialLoadingSubsidyAccessPolicy) {
+ if (isSubsidyAccessPolicyInitialLoading) {
return (
-
+
@@ -25,6 +28,12 @@ const BudgetDetailPage = () => {
);
}
+ // If the budget is intended to be a subsidy access policy (by presence of a policy UUID),
+ // and the subsidy access policy is not found, show 404 messaging.
+ if (subsidyAccessPolicyId && isSubsidyAccessPolicyError && error?.customAttributes.httpErrorStatus === 404) {
+ return ;
+ }
+
return (
diff --git a/src/components/learner-credit-management/BudgetDetailPageWrapper.jsx b/src/components/learner-credit-management/BudgetDetailPageWrapper.jsx
index 1651094dd4..9d6d95943d 100644
--- a/src/components/learner-credit-management/BudgetDetailPageWrapper.jsx
+++ b/src/components/learner-credit-management/BudgetDetailPageWrapper.jsx
@@ -7,7 +7,11 @@ import Hero from '../Hero';
const PAGE_TITLE = 'Learner Credit Management';
-const BudgetDetailPageWrapper = ({ subsidyAccessPolicy, children }) => {
+const BudgetDetailPageWrapper = ({
+ subsidyAccessPolicy,
+ includeHero,
+ children,
+}) => {
// display name is an optional field, and may not be set for all budgets so fallback to "Overview"
// similar to the display name logic for budgets on the overview page route.
const budgetDisplayName = subsidyAccessPolicy?.displayName || 'Overview';
@@ -15,7 +19,7 @@ const BudgetDetailPageWrapper = ({ subsidyAccessPolicy, children }) => {
return (
<>
-
+ {includeHero && }
{children}
@@ -26,6 +30,12 @@ const BudgetDetailPageWrapper = ({ subsidyAccessPolicy, children }) => {
BudgetDetailPageWrapper.propTypes = {
children: PropTypes.node.isRequired,
subsidyAccessPolicy: PropTypes.shape(),
+ includeHero: PropTypes.bool,
+};
+
+BudgetDetailPageWrapper.defaultProps = {
+ includeHero: true,
+ subsidyAccessPolicy: undefined,
};
export default BudgetDetailPageWrapper;
diff --git a/src/components/learner-credit-management/cards/AssignmentModalContent.jsx b/src/components/learner-credit-management/cards/AssignmentModalContent.jsx
index 65bfd3e672..e13e317d58 100644
--- a/src/components/learner-credit-management/cards/AssignmentModalContent.jsx
+++ b/src/components/learner-credit-management/cards/AssignmentModalContent.jsx
@@ -13,18 +13,25 @@ import BaseCourseCard from './BaseCourseCard';
import { formatPrice, useBudgetId, useSubsidyAccessPolicy } from '../data';
import { ImpactOnYourLearnerCreditBudget, ManagingThisAssignment, NextStepsForAssignedLearners } from './Collapsibles';
-const AssignmentModalContent = ({ course }) => {
- const [emailAddresses, setEmailAddresses] = useState('');
+const AssignmentModalContent = ({ course, onEmailAddressesChange }) => {
+ const [emailAddressesInputValue, setEmailAddressesInputValue] = useState('');
const { subsidyAccessPolicyId } = useBudgetId();
const { data: subsidyAccessPolicy } = useSubsidyAccessPolicy(subsidyAccessPolicyId);
+ const handleEmailAddressInputChange = (e) => {
+ const inputValue = e.target.value;
+ const emailAddresses = inputValue.split('\n').filter((email) => email.trim().length > 0);
+ setEmailAddressesInputValue(inputValue);
+ onEmailAddressesChange(emailAddresses);
+ };
+
return (
Use Learner Credit to assign this course
-
+
@@ -33,8 +40,8 @@ const AssignmentModalContent = ({ course }) => {
setEmailAddresses(e.target.value)}
+ value={emailAddressesInputValue}
+ onChange={handleEmailAddressInputChange}
floatingLabel="Learner email addresses"
rows={10}
data-hj-suppress
@@ -78,6 +85,7 @@ const AssignmentModalContent = ({ course }) => {
AssignmentModalContent.propTypes = {
course: PropTypes.shape().isRequired, // Pass-thru prop to `BaseCourseCard`
+ onEmailAddressesChange: PropTypes.func.isRequired,
};
export default AssignmentModalContent;
diff --git a/src/components/learner-credit-management/cards/BaseCourseCard.jsx b/src/components/learner-credit-management/cards/BaseCourseCard.jsx
index ef81a4f620..a9d0832b8b 100644
--- a/src/components/learner-credit-management/cards/BaseCourseCard.jsx
+++ b/src/components/learner-credit-management/cards/BaseCourseCard.jsx
@@ -67,7 +67,7 @@ const BaseCourseCard = ({
orientation={isExtraSmall ? 'horizontal' : 'vertical'}
textElement={isExecEdCourseType ? execEdEnrollmentInfo : courseEnrollmentInfo}
>
- {CardFooterActions && }
+ {CardFooterActions && }
diff --git a/src/components/learner-credit-management/cards/CourseCard.jsx b/src/components/learner-credit-management/cards/CourseCard.jsx
index df2e7379d8..2e1ec52cdb 100644
--- a/src/components/learner-credit-management/cards/CourseCard.jsx
+++ b/src/components/learner-credit-management/cards/CourseCard.jsx
@@ -8,7 +8,7 @@ import BaseCourseCard from './BaseCourseCard';
const { BUTTON_ACTION } = CARD_TEXT;
-const CourseCardFooterActions = (course) => {
+const CourseCardFooterActions = ({ course }) => {
const { linkToCourse } = course;
return [
diff --git a/src/components/learner-credit-management/cards/CourseCard.test.jsx b/src/components/learner-credit-management/cards/CourseCard.test.jsx
index be225b0c9a..bc6b38aba8 100644
--- a/src/components/learner-credit-management/cards/CourseCard.test.jsx
+++ b/src/components/learner-credit-management/cards/CourseCard.test.jsx
@@ -1,17 +1,37 @@
import React from 'react';
-import { screen, within } from '@testing-library/react';
+import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom/extend-expect';
import { Provider } from 'react-redux';
import thunk from 'redux-thunk';
import configureMockStore from 'redux-mock-store';
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { QueryClientProvider, useQueryClient } from '@tanstack/react-query';
import { AppContext } from '@edx/frontend-platform/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { renderWithRouter } from '@edx/frontend-enterprise-utils';
import CourseCard from './CourseCard';
-import { formatPrice, useSubsidyAccessPolicy } from '../data';
+import {
+ formatPrice,
+ learnerCreditManagementQueryKeys,
+ useBudgetId,
+ useSubsidyAccessPolicy,
+} from '../data';
+import { getButtonElement, queryClient } from '../../test/testUtils';
+
+import EnterpriseAccessApiService from '../../../data/services/EnterpriseAccessApiService';
+
+jest.mock('@tanstack/react-query', () => ({
+ ...jest.requireActual('@tanstack/react-query'),
+ useQueryClient: jest.fn(),
+}));
+
+jest.mock('../data', () => ({
+ ...jest.requireActual('../data'),
+ useBudgetId: jest.fn(),
+ useSubsidyAccessPolicy: jest.fn(),
+}));
+jest.mock('../../../data/services/EnterpriseAccessApiService');
const originalData = {
availability: ['Upcoming'],
@@ -50,7 +70,6 @@ const execEdData = {
partners: [{ logo_image_url: '', name: 'Course Provider' }],
title: 'Exec Ed Title',
};
-
const execEdProps = {
original: execEdData,
};
@@ -66,14 +85,6 @@ const initialStoreState = {
},
};
-const queryClient = new QueryClient({
- defaultOptions: {
- queries: {
- retry: false,
- },
- },
-});
-
const mockSubsidyAccessPolicy = {
uuid: 'test-subsidy-access-policy-uuid',
displayName: 'Test Subsidy Access Policy',
@@ -81,11 +92,7 @@ const mockSubsidyAccessPolicy = {
spendAvailableUsd: 50000,
},
};
-
-jest.mock('../data', () => ({
- ...jest.requireActual('../data'),
- useSubsidyAccessPolicy: jest.fn(),
-}));
+const mockLearnerEmails = ['hello@example.com', 'world@example.com'];
const CourseCardWrapper = ({
initialState = initialStoreState,
@@ -94,7 +101,7 @@ const CourseCardWrapper = ({
const store = getMockStore({ ...initialState });
return (
-
+
{
data: mockSubsidyAccessPolicy,
isLoading: false,
});
+ useBudgetId.mockReturnValue({ subsidyAccessPolicyId: mockSubsidyAccessPolicy.uuid });
});
afterEach(() => {
@@ -139,7 +147,7 @@ describe('Course card works as expected', () => {
const viewCourseCTA = screen.getByText('View course', { selector: 'a' });
expect(viewCourseCTA).toBeInTheDocument();
expect(viewCourseCTA.href).toContain('https://enterprise.stage.edx.org/test-enterprise-slug/course/course-123x');
- const assignCourseCTA = screen.getByText('Assign', { selector: 'button' });
+ const assignCourseCTA = getButtonElement('Assign');
expect(assignCourseCTA).toBeInTheDocument();
});
@@ -167,9 +175,42 @@ describe('Course card works as expected', () => {
expect(viewCourseCTA.href).toContain('https://enterprise.stage.edx.org/test-enterprise-slug/executive-education-2u/course/exec-ed-course-123x');
});
- test('opens assignment modal', () => {
+ test.each([
+ { shouldSubmitAssignments: true, hasAllocationException: true },
+ { shouldSubmitAssignments: true, hasAllocationException: false },
+ { shouldSubmitAssignments: false, hasAllocationException: false },
+ ])('opens assignment modal, submits assignments successfully (%s)', async ({ shouldSubmitAssignments, hasAllocationException }) => {
+ const mockAllocateContentAssignments = jest.spyOn(EnterpriseAccessApiService, 'allocateContentAssignments');
+ if (hasAllocationException) {
+ mockAllocateContentAssignments.mockRejectedValue(new Error('oops'));
+ } else {
+ mockAllocateContentAssignments.mockResolvedValue({
+ data: {
+ updated: [],
+ created: mockLearnerEmails.map(learnerEmail => ({
+ uuid: '095be615-a8ad-4c33-8e9c-c7612fbf6c9f',
+ assignment_configuration: 'fd456a98-653b-41e9-94d1-94d7b136832a',
+ learner_email: learnerEmail,
+ lms_user_id: 0,
+ content_key: 'string',
+ content_title: 'string',
+ content_quantity: 0,
+ state: 'allocated',
+ transaction_uuid: '3a6bcbed-b7dc-4791-84fe-b20f12be4001',
+ last_notification_at: '2019-08-24T14:15:22Z',
+ actions: [],
+ })),
+ no_change: [],
+ },
+ });
+ }
+ useBudgetId.mockReturnValue({ subsidyAccessPolicyId: mockSubsidyAccessPolicy.uuid });
+ const mockInvalidateQueries = jest.fn();
+ useQueryClient.mockReturnValue({
+ invalidateQueries: mockInvalidateQueries,
+ });
renderWithRouter();
- const assignCourseCTA = screen.getByText('Assign', { selector: 'button' });
+ const assignCourseCTA = getButtonElement('Assign');
expect(assignCourseCTA).toBeInTheDocument();
userEvent.click(assignCourseCTA);
@@ -190,7 +231,7 @@ describe('Course card works as expected', () => {
expect(cardImage).toBeInTheDocument();
expect(cardImage.src).toBeDefined();
expect(modalCourseCard.queryByText('View course', { selector: 'a' })).not.toBeInTheDocument();
- expect(modalCourseCard.queryByText('Assign', { selector: 'button' })).not.toBeInTheDocument();
+ expect(getButtonElement('Assign', { screenOverride: modalCourseCard, isQueryByRole: true })).not.toBeInTheDocument();
// Verify empty state and textarea can accept emails
expect(assignmentModal.getByText('Assign to')).toBeInTheDocument();
@@ -227,13 +268,43 @@ describe('Course card works as expected', () => {
// Verify modal footer
expect(assignmentModal.getByText('Help Center: Course Assignments')).toBeInTheDocument();
- const cancelAssignmentCTA = assignmentModal.getByText('Cancel', { selector: 'button' });
+ const cancelAssignmentCTA = getButtonElement('Cancel', { screenOverride: assignmentModal });
expect(cancelAssignmentCTA).toBeInTheDocument();
- const submitAssignmentCTA = assignmentModal.getByText('Assign', { selector: 'button' });
+ const submitAssignmentCTA = getButtonElement('Assign', { screenOverride: assignmentModal });
expect(submitAssignmentCTA).toBeInTheDocument();
- // Verify modal closes
- userEvent.click(cancelAssignmentCTA);
- expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ if (shouldSubmitAssignments) {
+ // Verify assignment is submitted successfully
+ userEvent.click(submitAssignmentCTA);
+ await waitFor(() => expect(mockAllocateContentAssignments).toHaveBeenCalledTimes(1));
+ expect(mockAllocateContentAssignments).toHaveBeenCalledWith(
+ mockSubsidyAccessPolicy.uuid,
+ expect.objectContaining({
+ content_price_cents: 10000,
+ content_key: 'course-123x',
+ learner_emails: mockLearnerEmails,
+ }),
+ );
+
+ if (hasAllocationException) {
+ // Verify error state
+ expect(getButtonElement('Try again', { screenOverride: assignmentModal })).toHaveAttribute('aria-disabled', 'false');
+ } else {
+ // Verify success state
+ expect(mockInvalidateQueries).toHaveBeenCalledTimes(1);
+ expect(mockInvalidateQueries).toHaveBeenCalledWith({
+ queryKey: learnerCreditManagementQueryKeys.budget(mockSubsidyAccessPolicy.uuid),
+ });
+ expect(getButtonElement('Assigned', { screenOverride: assignmentModal })).toHaveAttribute('aria-disabled', 'true');
+ // Verify modal closes
+ await waitFor(() => {
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+ }
+ } else {
+ // Otherwise, verify modal closes when cancel button is clicked
+ userEvent.click(cancelAssignmentCTA);
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ }
});
});
diff --git a/src/components/learner-credit-management/cards/NewAssignmentModalButton.jsx b/src/components/learner-credit-management/cards/NewAssignmentModalButton.jsx
index 1b63968106..e23dbb5273 100644
--- a/src/components/learner-credit-management/cards/NewAssignmentModalButton.jsx
+++ b/src/components/learner-credit-management/cards/NewAssignmentModalButton.jsx
@@ -1,16 +1,67 @@
-import React from 'react';
+import React, { useState } from 'react';
import PropTypes from 'prop-types';
+import { useRouteMatch, useHistory, generatePath } from 'react-router-dom';
import {
FullscreenModal,
ActionRow,
Button,
useToggle,
Hyperlink,
+ StatefulButton,
} from '@edx/paragon';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { snakeCaseObject } from '@edx/frontend-platform/utils';
+
import AssignmentModalContent from './AssignmentModalContent';
+import EnterpriseAccessApiService from '../../../data/services/EnterpriseAccessApiService';
+import { learnerCreditManagementQueryKeys, useBudgetId } from '../data';
+
+const useAllocateContentAssignments = () => useMutation({
+ mutationFn: async ({
+ subsidyAccessPolicyId,
+ payload,
+ }) => EnterpriseAccessApiService.allocateContentAssignments(subsidyAccessPolicyId, payload),
+});
const NewAssignmentModalButton = ({ course, children }) => {
+ const history = useHistory();
+ const routeMatch = useRouteMatch();
+ const queryClient = useQueryClient();
+ const { subsidyAccessPolicyId } = useBudgetId();
+
const [isOpen, open, close] = useToggle(false);
+ const [learnerEmails, setLearnerEmails] = useState([]);
+ const [assignButtonState, setAssignButtonState] = useState('default');
+
+ const { mutate } = useAllocateContentAssignments();
+
+ const pathToActivityTab = generatePath(routeMatch.path, { budgetId: subsidyAccessPolicyId, activeTabKey: 'activity' });
+
+ const handleAllocateContentAssignments = () => {
+ const payload = snakeCaseObject({
+ contentPriceCents: course.normalizedMetadata.contentPrice * 100, // Convert to USD cents
+ contentKey: course.key,
+ learnerEmails,
+ });
+ const mutationArgs = {
+ subsidyAccessPolicyId,
+ payload,
+ };
+ setAssignButtonState('pending');
+ mutate(mutationArgs, {
+ onSuccess: () => {
+ setAssignButtonState('complete');
+ queryClient.invalidateQueries({
+ queryKey: learnerCreditManagementQueryKeys.budget(subsidyAccessPolicyId),
+ });
+ close();
+ history.push(pathToActivityTab);
+ },
+ onError: () => {
+ setAssignButtonState('error');
+ },
+ });
+ };
return (
<>
@@ -27,12 +78,21 @@ const NewAssignmentModalButton = ({ course, children }) => {
- {/* TODO: https://2u-internal.atlassian.net/browse/ENT-7826 */}
-
+
)}
>
-
+
>
);
diff --git a/src/components/learner-credit-management/data/hooks/useBudgetDetailActivityOverview.test.jsx b/src/components/learner-credit-management/data/hooks/useBudgetDetailActivityOverview.test.jsx
index cdb3b3e9bf..3290ef90dc 100644
--- a/src/components/learner-credit-management/data/hooks/useBudgetDetailActivityOverview.test.jsx
+++ b/src/components/learner-credit-management/data/hooks/useBudgetDetailActivityOverview.test.jsx
@@ -1,4 +1,4 @@
-import { QueryClientProvider, QueryClient } from '@tanstack/react-query';
+import { QueryClientProvider } from '@tanstack/react-query';
import { renderHook } from '@testing-library/react-hooks';
import useBudgetDetailActivityOverview from './useBudgetDetailActivityOverview';
@@ -12,22 +12,15 @@ import {
mockEnterpriseOfferId,
mockSubsidyAccessPolicyUUID,
} from '../tests/constants';
+import { queryClient } from '../../../test/testUtils';
jest.mock('./useBudgetId');
jest.mock('./useSubsidyAccessPolicy');
const mockEnterpriseUUID = 'mock-enterprise-uuid';
-const queryClient = new QueryClient({
- defaultOptions: {
- queries: {
- retry: false,
- },
- },
-});
-
const wrapper = ({ children }) => (
-
+
{children}
);
diff --git a/src/components/learner-credit-management/data/hooks/useSubsidyAccessPolicy.test.jsx b/src/components/learner-credit-management/data/hooks/useSubsidyAccessPolicy.test.jsx
index 30edc0f3cc..cc773fcb79 100644
--- a/src/components/learner-credit-management/data/hooks/useSubsidyAccessPolicy.test.jsx
+++ b/src/components/learner-credit-management/data/hooks/useSubsidyAccessPolicy.test.jsx
@@ -1,8 +1,9 @@
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { QueryClientProvider } from '@tanstack/react-query';
import { renderHook } from '@testing-library/react-hooks';
import useSubsidyAccessPolicy from './useSubsidyAccessPolicy'; // Import the hook
import EnterpriseAccessApiService from '../../../../data/services/EnterpriseAccessApiService';
+import { queryClient } from '../../../test/testUtils';
const mockSubsidyAccessPolicyUUID = '9af340a9-48de-4d94-976d-e2282b9eb7f3';
const mockAssignmentConfiguration = { uuid: 'test-assignment-configuration-uuid' };
@@ -18,16 +19,8 @@ jest.mock('../../../../data/services/EnterpriseAccessApiService', () => ({
}),
}));
-const queryClient = new QueryClient({
- defaultOptions: {
- queries: {
- retry: false,
- },
- },
-});
-
const wrapper = ({ children }) => (
- {children}
+ {children}
);
describe('useSubsidyAccessPolicy', () => {
diff --git a/src/components/learner-credit-management/tests/BudgetDetailPage.test.jsx b/src/components/learner-credit-management/tests/BudgetDetailPage.test.jsx
index 1be7a7488a..6c0a0c8040 100644
--- a/src/components/learner-credit-management/tests/BudgetDetailPage.test.jsx
+++ b/src/components/learner-credit-management/tests/BudgetDetailPage.test.jsx
@@ -1,6 +1,6 @@
import React from 'react';
import { useParams } from 'react-router-dom';
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { QueryClientProvider } from '@tanstack/react-query';
import { Provider } from 'react-redux';
import thunk from 'redux-thunk';
import configureMockStore from 'redux-mock-store';
@@ -29,6 +29,7 @@ import {
mockSubsidyAccessPolicyUUID,
mockEnterpriseOfferId,
} from '../data/tests/constants';
+import { queryClient } from '../../test/testUtils';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
@@ -98,8 +99,6 @@ const defaultEnterpriseSubsidiesContextValue = {
isLoading: false,
};
-const queryClient = new QueryClient();
-
const BudgetDetailPageWrapper = ({
initialState = initialStoreState,
enterpriseSubsidiesContextValue = defaultEnterpriseSubsidiesContextValue,
@@ -107,7 +106,7 @@ const BudgetDetailPageWrapper = ({
}) => {
const store = getMockStore({ ...initialState });
return (
-
+
@@ -124,6 +123,24 @@ describe('', () => {
jest.resetAllMocks();
});
+ it('renders page not found messaging if budget is a subsidy access policy, but the REST API returns a 404', () => {
+ useParams.mockReturnValue({
+ budgetId: 'a52e6548-649f-4576-b73f-c5c2bee25e9c',
+ activeTabKey: 'activity',
+ });
+ useSubsidyAccessPolicy.mockReturnValue({
+ isInitialLoading: false,
+ isError: true,
+ error: { customAttributes: { httpErrorStatus: 404 } },
+ });
+ useBudgetDetailActivityOverview.mockReturnValue({
+ isLoading: false,
+ data: mockEmptyStateBudgetDetailActivityOverview,
+ });
+ renderWithRouter();
+ expect(screen.getByText('404', { selector: 'h1' }));
+ });
+
it.each([
{ displayName: null },
{ displayName: 'Test Budget Display Name' },
diff --git a/src/components/learner-credit-management/tests/CatalogSearch.test.jsx b/src/components/learner-credit-management/tests/CatalogSearch.test.jsx
index ab6a0e6a01..dc17350f21 100644
--- a/src/components/learner-credit-management/tests/CatalogSearch.test.jsx
+++ b/src/components/learner-credit-management/tests/CatalogSearch.test.jsx
@@ -6,8 +6,8 @@ import {
} from '@edx/frontend-enterprise-catalog-search';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { screen } from '@testing-library/react';
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
-import { renderWithRouter } from '../../test/testUtils';
+import { QueryClientProvider } from '@tanstack/react-query';
+import { queryClient, renderWithRouter } from '../../test/testUtils';
import CatalogSearch from '../search/CatalogSearch';
import { useBudgetId, useSubsidyAccessPolicy } from '../data';
@@ -21,10 +21,9 @@ jest.mock('react-instantsearch-dom', () => ({
jest.mock('../data');
const DEFAULT_SEARCH_CONTEXT_VALUE = { refinements: {} };
-const queryClient = new QueryClient();
const SearchDataWrapper = ({ children, searchContextValue }) => (
-
+
{
const store = getMockStore({ ...initialStoreState });
return (
-
-
- {children}
-
-
+
+
+
+ {children}
+
+
+
);
};
@@ -153,7 +156,7 @@ describe('Main Catalogs view works as expected', () => {
});
test('all courses rendered when search results available', async () => {
- render(
+ renderWithRouter(
diff --git a/src/components/settings/SettingsSSOTab/tests/NewExistingSSOConfigs.test.jsx b/src/components/settings/SettingsSSOTab/tests/NewExistingSSOConfigs.test.jsx
index cd0b7dfdd1..5ac24d2601 100644
--- a/src/components/settings/SettingsSSOTab/tests/NewExistingSSOConfigs.test.jsx
+++ b/src/components/settings/SettingsSSOTab/tests/NewExistingSSOConfigs.test.jsx
@@ -1,9 +1,6 @@
import React from 'react';
import '@testing-library/jest-dom/extend-expect';
-import {
- QueryClient,
- QueryClientProvider,
-} from '@tanstack/react-query';
+import { QueryClientProvider } from '@tanstack/react-query';
import userEvent from '@testing-library/user-event';
import {
act,
@@ -19,12 +16,7 @@ import { features } from '../../../../config';
import NewExistingSSOConfigs from '../NewExistingSSOConfigs';
import { SSOConfigContext, SSO_INITIAL_STATE } from '../SSOConfigContext';
import LmsApiService from '../../../../data/services/LmsApiService';
-
-const queryClient = new QueryClient({
- queries: {
- retry: true, // optional: you may disable automatic query retries for all queries or on a per-query basis.
- },
-});
+import { queryClient } from '../../../test/testUtils';
jest.mock('../../utils');
jest.mock('../../../../data/services/LmsApiService');
@@ -152,7 +144,7 @@ const setupNewExistingSSOConfigs = (configs) => {
features.AUTH0_SELF_SERVICE_INTEGRATION = true;
return render(
-
+
mockStore(aStore);
@@ -102,7 +95,7 @@ describe('SAML Config Tab', () => {
}));
await waitFor(() => render(
-
+
,
diff --git a/src/components/test/testUtils.jsx b/src/components/test/testUtils.jsx
index e4a22ba445..931afffc6e 100644
--- a/src/components/test/testUtils.jsx
+++ b/src/components/test/testUtils.jsx
@@ -2,8 +2,10 @@
import React from 'react';
import { Router } from 'react-router-dom';
import { createMemoryHistory } from 'history';
-import { render, screen } from '@testing-library/react';
+import { render, screen as rtlScreen } from '@testing-library/react';
+import { QueryClient } from '@tanstack/react-query';
+// TODO: this could likely be replaced by `renderWithRouter` from `@edx/frontend-enterprise-utils`.
export function renderWithRouter(
ui,
{
@@ -30,4 +32,25 @@ export function findElementWithText(container, type, text) {
return [...elements].find((elem) => elem.innerHTML.includes(text));
}
-export const getButtonElement = (buttonText) => screen.getByRole('button', { name: buttonText });
+export const getButtonElement = (buttonText, options = {}) => {
+ const {
+ screenOverride,
+ isQueryByRole,
+ } = options;
+ const screen = screenOverride || rtlScreen;
+ if (isQueryByRole) {
+ return screen.queryByRole('button', { name: buttonText });
+ }
+ return screen.getByRole('button', { name: buttonText });
+};
+
+export function queryClient(options = {}) {
+ return new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ },
+ ...options,
+ },
+ });
+}
diff --git a/src/data/services/EnterpriseAccessApiService.js b/src/data/services/EnterpriseAccessApiService.js
index be704fe658..ad431633c3 100644
--- a/src/data/services/EnterpriseAccessApiService.js
+++ b/src/data/services/EnterpriseAccessApiService.js
@@ -166,6 +166,11 @@ class EnterpriseAccessApiService {
const url = `${EnterpriseAccessApiService.baseUrl}/subsidy-access-policies/${subsidyAccessPolicyUUID}/`;
return EnterpriseAccessApiService.apiClient().get(url);
}
+
+ static allocateContentAssignments(subsidyAccessPolicyUUID, payload) {
+ const url = `${EnterpriseAccessApiService.baseUrl}/policy-allocation/${subsidyAccessPolicyUUID}/allocate/`;
+ return EnterpriseAccessApiService.apiClient().post(url, payload);
+ }
}
export default EnterpriseAccessApiService;
diff --git a/src/data/services/tests/EnterpriseAccessApiService.test.js b/src/data/services/tests/EnterpriseAccessApiService.test.js
index fd1497705d..7fd27b22a1 100644
--- a/src/data/services/tests/EnterpriseAccessApiService.test.js
+++ b/src/data/services/tests/EnterpriseAccessApiService.test.js
@@ -152,4 +152,17 @@ describe('EnterpriseAccessApiService', () => {
`${enterpriseAccessBaseUrl}/api/v1/subsidy-access-policies/${mockSubsidyAccessPolicyUUID}/`,
);
});
+
+ test('allocateContentAssignments calls enterprise-access allocate POST API to create assignments', () => {
+ const payload = {
+ learner_emails: ['edx@example.com'],
+ content_key: 'edX+DemoX',
+ content_price_cents: 19900,
+ };
+ EnterpriseAccessApiService.allocateContentAssignments(mockSubsidyAccessPolicyUUID, payload);
+ expect(axios.post).toBeCalledWith(
+ `${enterpriseAccessBaseUrl}/api/v1/policy-allocation/${mockSubsidyAccessPolicyUUID}/allocate/`,
+ payload,
+ );
+ });
});
diff --git a/src/utils.js b/src/utils.js
index 871af1524a..207d25c8bc 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -400,6 +400,18 @@ const pollAsync = async (pollFunc, timeout, interval, checkFunc) => {
return false;
};
+/**
+ * Modifies the retry behavior of queries to retry up to max 3 times (default) or if
+ * the error returned by the query is a 404 HTTP status code (not found). This configuration
+ * may be overridden per-query, as needed.
+ */
+function defaultQueryClientRetryHandler(failureCount, err) {
+ if (failureCount >= 3 || err.customAttributes.httpErrorStatus === 404) {
+ return false;
+ }
+ return true;
+}
+
export {
camelCaseDict,
camelCaseDictArray,
@@ -433,4 +445,5 @@ export {
capitalizeFirstLetter,
pollAsync,
isNotValidNumberString,
+ defaultQueryClientRetryHandler,
};
diff --git a/src/utils.test.js b/src/utils.test.js
index c2c713bdc1..977582e89c 100644
--- a/src/utils.test.js
+++ b/src/utils.test.js
@@ -5,6 +5,7 @@ import {
snakeCaseFormData,
pollAsync,
isValidNumber,
+ defaultQueryClientRetryHandler,
} from './utils';
describe('utils', () => {
@@ -93,4 +94,24 @@ describe('utils', () => {
expect(isValidNumber(undefined)).toEqual(false);
});
});
+
+ describe('defaultQueryClientRetryHandler', () => {
+ const mockError404 = { customAttributes: { httpErrorStatus: 404 } };
+ const mockError500 = { customAttributes: { httpErrorStatus: 500 } };
+
+ it.each([3, 4])('return false if failureCount >= 3 (failureCount: %s)', (failureCount) => {
+ const result = defaultQueryClientRetryHandler(failureCount, mockError500);
+ expect(result).toEqual(false);
+ });
+
+ it('return false if error is a 404 HTTP status code', () => {
+ const result = defaultQueryClientRetryHandler(1, mockError404);
+ expect(result).toEqual(false);
+ });
+
+ it.each([1, 2])('return true if first failure and error is not a 404 (failureCount: %s)', (failureCount) => {
+ const result = defaultQueryClientRetryHandler(failureCount, mockError500);
+ expect(result).toEqual(true);
+ });
+ });
});