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); + }); + }); });