Skip to content

Commit

Permalink
fix: integrate with allocate API for creating assignments, update g…
Browse files Browse the repository at this point in the history
…lobal retry behavior on queries, handle 404 subsidy access policy (#1080)
  • Loading branch information
adamstankiewicz authored Nov 3, 2023
1 parent 8211761 commit e97f7ae
Show file tree
Hide file tree
Showing 21 changed files with 332 additions and 104 deletions.
2 changes: 2 additions & 0 deletions src/components/App/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<>
<Skeleton count={2} height={180} />
Expand Down
15 changes: 12 additions & 3 deletions src/components/learner-credit-management/BudgetDetailPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<BudgetDetailPageWrapper>
<BudgetDetailPageWrapper includeHero={false}>
<Skeleton height={25} />
<Skeleton height={50} />
<Skeleton height={360} />
Expand All @@ -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 <NotFoundPage />;
}

return (
<BudgetDetailPageWrapper subsidyAccessPolicy={subsidyAccessPolicy}>
<Stack gap={4}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,19 @@ 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';
const helmetPageTitle = budgetDisplayName ? `${budgetDisplayName} - ${PAGE_TITLE}` : PAGE_TITLE;
return (
<>
<Helmet title={helmetPageTitle} />
<Hero title={PAGE_TITLE} />
{includeHero && <Hero title={PAGE_TITLE} />}
<Container className="py-3" fluid>
{children}
</Container>
Expand All @@ -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;
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Container size="lg" className="py-3">
<Stack gap={5}>
<Row>
<Col>
<h3 className="mb-4">Use Learner Credit to assign this course</h3>
<BaseCourseCard original={course} className="rounded-0 shadow-none" />
<BaseCourseCard original={course} cardClassName="shadow-none" />
</Col>
</Row>
<Row>
Expand All @@ -33,8 +40,8 @@ const AssignmentModalContent = ({ course }) => {
<Form.Group className="mb-5">
<Form.Control
as="textarea"
value={emailAddresses}
onChange={(e) => setEmailAddresses(e.target.value)}
value={emailAddressesInputValue}
onChange={handleEmailAddressInputChange}
floatingLabel="Learner email addresses"
rows={10}
data-hj-suppress
Expand Down Expand Up @@ -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;
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ const BaseCourseCard = ({
orientation={isExtraSmall ? 'horizontal' : 'vertical'}
textElement={isExecEdCourseType ? execEdEnrollmentInfo : courseEnrollmentInfo}
>
{CardFooterActions && <CardFooterActions {...courseCardMetadata} />}
{CardFooterActions && <CardFooterActions course={courseCardMetadata} />}
</Card.Footer>
</Card.Body>
</Card>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import BaseCourseCard from './BaseCourseCard';

const { BUTTON_ACTION } = CARD_TEXT;

const CourseCardFooterActions = (course) => {
const CourseCardFooterActions = ({ course }) => {
const { linkToCourse } = course;

return [
Expand Down
125 changes: 98 additions & 27 deletions src/components/learner-credit-management/cards/CourseCard.test.jsx
Original file line number Diff line number Diff line change
@@ -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'],
Expand Down Expand Up @@ -50,7 +70,6 @@ const execEdData = {
partners: [{ logo_image_url: '', name: 'Course Provider' }],
title: 'Exec Ed Title',
};

const execEdProps = {
original: execEdData,
};
Expand All @@ -66,26 +85,14 @@ const initialStoreState = {
},
};

const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});

const mockSubsidyAccessPolicy = {
uuid: 'test-subsidy-access-policy-uuid',
displayName: 'Test Subsidy Access Policy',
aggregates: {
spendAvailableUsd: 50000,
},
};

jest.mock('../data', () => ({
...jest.requireActual('../data'),
useSubsidyAccessPolicy: jest.fn(),
}));
const mockLearnerEmails = ['[email protected]', '[email protected]'];

const CourseCardWrapper = ({
initialState = initialStoreState,
Expand All @@ -94,7 +101,7 @@ const CourseCardWrapper = ({
const store = getMockStore({ ...initialState });

return (
<QueryClientProvider client={queryClient}>
<QueryClientProvider client={queryClient()}>
<IntlProvider locale="en">
<Provider store={store}>
<AppContext.Provider
Expand All @@ -116,6 +123,7 @@ describe('Course card works as expected', () => {
data: mockSubsidyAccessPolicy,
isLoading: false,
});
useBudgetId.mockReturnValue({ subsidyAccessPolicyId: mockSubsidyAccessPolicy.uuid });
});

afterEach(() => {
Expand All @@ -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();
});

Expand Down Expand Up @@ -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(<CourseCardWrapper {...defaultProps} />);
const assignCourseCTA = screen.getByText('Assign', { selector: 'button' });
const assignCourseCTA = getButtonElement('Assign');
expect(assignCourseCTA).toBeInTheDocument();
userEvent.click(assignCourseCTA);

Expand All @@ -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();
Expand Down Expand Up @@ -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();
}
});
});
Loading

0 comments on commit e97f7ae

Please sign in to comment.