Skip to content

Commit

Permalink
feat: display success toast on assignment allocation (#1083)
Browse files Browse the repository at this point in the history
  • Loading branch information
adamstankiewicz authored Nov 7, 2023
1 parent 16373b9 commit d518a4d
Show file tree
Hide file tree
Showing 8 changed files with 208 additions and 17 deletions.
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Helmet } from 'react-helmet';
import { Container } from '@edx/paragon';
import { Container, Toast } from '@edx/paragon';

import Hero from '../Hero';
import { useSuccessfulAssignmentToastContextValue } from './data';

const PAGE_TITLE = 'Learner Credit Management';

export const BudgetDetailPageContext = React.createContext();

const BudgetDetailPageWrapper = ({
subsidyAccessPolicy,
includeHero,
Expand All @@ -16,14 +19,34 @@ const BudgetDetailPageWrapper = ({
// 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;

const successfulAssignmentToastContextValue = useSuccessfulAssignmentToastContextValue();
const {
isSuccessfulAssignmentAllocationToastOpen,
successfulAssignmentAllocationToastMessage,
closeToastForAssignmentAllocation,
} = successfulAssignmentToastContextValue;

return (
<>
<BudgetDetailPageContext.Provider value={successfulAssignmentToastContextValue}>
<Helmet title={helmetPageTitle} />
{includeHero && <Hero title={PAGE_TITLE} />}
<Container className="py-3" fluid>
{children}
</Container>
</>
{/**
Successful assignment allocation Toast notification. It is rendered here to guarantee that the
Toast component will not be unmounted when the user programmatically navigates to the "Activity"
tab, which will unmount the course cards that rendered the assignment modal. Thus, the Toast must
be rendered within the component tree that's common to both the "Activity" and "Overview" tabs.
*/}
<Toast
onClose={closeToastForAssignmentAllocation}
show={isSuccessfulAssignmentAllocationToastOpen}
>
{successfulAssignmentAllocationToastMessage}
</Toast>
</BudgetDetailPageContext.Provider>
);
};

Expand Down
37 changes: 29 additions & 8 deletions src/components/learner-credit-management/cards/CourseCard.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
import { getButtonElement, queryClient } from '../../test/testUtils';

import EnterpriseAccessApiService from '../../../data/services/EnterpriseAccessApiService';
import { BudgetDetailPageContext } from '../BudgetDetailPageWrapper';

jest.mock('@tanstack/react-query', () => ({
...jest.requireActual('@tanstack/react-query'),
Expand Down Expand Up @@ -94,8 +95,17 @@ const mockSubsidyAccessPolicy = {
};
const mockLearnerEmails = ['[email protected]', '[email protected]'];

const mockDisplaySuccessfulAssignmentToast = jest.fn();
const defaultBudgetDetailPageContextValue = {
isSuccessfulAssignmentAllocationToastOpen: false,
totalLearnersAssigned: undefined,
displayToastForAssignmentAllocation: mockDisplaySuccessfulAssignmentToast,
closeToastForAssignmentAllocation: jest.fn(),
};

const CourseCardWrapper = ({
initialState = initialStoreState,
budgetDetailPageContextValue = defaultBudgetDetailPageContextValue,
...rest
}) => {
const store = getMockStore({ ...initialState });
Expand All @@ -109,7 +119,9 @@ const CourseCardWrapper = ({
config: { ENTERPRISE_LEARNER_PORTAL_URL: mockLearnerPortal },
}}
>
<CourseCard {...rest} />
<BudgetDetailPageContext.Provider value={budgetDetailPageContextValue}>
<CourseCard {...rest} />
</BudgetDetailPageContext.Provider>
</AppContext.Provider>
</Provider>
</IntlProvider>
Expand Down Expand Up @@ -221,22 +233,26 @@ describe('Course card works as expected', () => {
errorReason: 'not_enough_value_in_subsidy',
shouldRetryAfterError: true,
},
{ shouldSubmitAssignments: true,
{
shouldSubmitAssignments: true,
hasAllocationException: true,
errorReason: 'policy_spend_limit_reached',
shouldRetryAfterError: false,
},
{ shouldSubmitAssignments: true,
{
shouldSubmitAssignments: true,
hasAllocationException: true,
errorReason: 'policy_spend_limit_reached',
shouldRetryAfterError: true,
},
{ shouldSubmitAssignments: true,
{
shouldSubmitAssignments: true,
hasAllocationException: true,
errorReason: null,
shouldRetryAfterError: false,
},
{ shouldSubmitAssignments: true,
{
shouldSubmitAssignments: true,
hasAllocationException: true,
errorReason: null,
shouldRetryAfterError: true,
Expand Down Expand Up @@ -363,7 +379,7 @@ describe('Course card works as expected', () => {
// Verify error states
if (hasAllocationException) {
expect(getButtonElement('Try again', { screenOverride: assignmentModal })).toHaveAttribute('aria-disabled', 'false');

// Assert the correct error modal is displayed
if (errorReason === 'content_not_in_catalog') {
const assignmentErrorModal = getAssignmentErrorModal();
Expand Down Expand Up @@ -393,17 +409,22 @@ describe('Course card works as expected', () => {
await simulateClickErrorModalExit(assignmentErrorModal);
}
}

} 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(() => {
// Verify all modals close (error modal + assignment modal)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();

// Verify toast notification was displayed
expect(mockDisplaySuccessfulAssignmentToast).toHaveBeenCalledTimes(1);
expect(mockDisplaySuccessfulAssignmentToast).toHaveBeenCalledWith({
totalLearnersAssigned: mockLearnerEmails.length,
});
});
}
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ const CreateAllocationErrorAlertModals = ({
const [isCatalogError, openCatalogErrorModal, closeCatalogErrorModal] = useToggle(false);
const [isSystemError, openSystemErrorModal, closeSystemErrorModal] = useToggle(false);
const [isBalanceError, openBalanceErrorModal, closeBalanceErrorModal] = useToggle(false);

/**
* Close all error modals.
* Helper function to close all error modals.
*/
const closeAllErrorModals = useCallback(() => {
const closeFns = [closeCatalogErrorModal, closeBalanceErrorModal, closeSystemErrorModal];
Expand All @@ -25,7 +25,7 @@ const CreateAllocationErrorAlertModals = ({
}, [closeCatalogErrorModal, closeBalanceErrorModal, closeSystemErrorModal]);

/**
* Retry the original action that caused the error.
* Retry the original action that caused the error and close all error modals.
*/
const handleErrorRetry = () => {
retry();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useContext, useState } from 'react';
import PropTypes from 'prop-types';
import { useRouteMatch, useHistory, generatePath } from 'react-router-dom';
import {
Expand All @@ -16,6 +16,7 @@ import AssignmentModalContent from './AssignmentModalContent';
import EnterpriseAccessApiService from '../../../data/services/EnterpriseAccessApiService';
import { learnerCreditManagementQueryKeys, useBudgetId } from '../data';
import CreateAllocationErrorAlertModals from './CreateAllocationErrorAlertModals';
import { BudgetDetailPageContext } from '../BudgetDetailPageWrapper';

const useAllocateContentAssignments = () => useMutation({
mutationFn: async ({
Expand All @@ -33,6 +34,7 @@ const NewAssignmentModalButton = ({ course, children }) => {
const [learnerEmails, setLearnerEmails] = useState([]);
const [assignButtonState, setAssignButtonState] = useState('default');
const [createAssignmentsErrorReason, setCreateAssignmentsErrorReason] = useState();
const { displayToastForAssignmentAllocation } = useContext(BudgetDetailPageContext);

const { mutate } = useAllocateContentAssignments();

Expand Down Expand Up @@ -62,6 +64,7 @@ const NewAssignmentModalButton = ({ course, children }) => {
queryKey: learnerCreditManagementQueryKeys.budget(subsidyAccessPolicyId),
});
handleCloseAssignmentModal();
displayToastForAssignmentAllocation({ totalLearnersAssigned: learnerEmails.length });
history.push(pathToActivityTab);
},
onError: (err) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ export { default as useSubsidyAccessPolicy } from './useSubsidyAccessPolicy';
export { default as usePathToCatalogTab } from './usePathToCatalogTab';
export { default as useBudgetDetailActivityOverview } from './useBudgetDetailActivityOverview';
export { default as useIsLargeOrGreater } from './useIsLargeOrGreater';
export { default as useSuccessfulAssignmentToastContextValue } from './useSuccessfulAssignmentToastContextValue';
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { useCallback, useMemo, useState } from 'react';

const useSuccessfulAssignmentToastContextValue = () => {
const [isToastOpen, setIsToastOpen] = useState(false);
const [learnersAssignedCount, setLearnersAssignedCount] = useState();

const handleDisplayToast = useCallback(({ totalLearnersAssigned }) => {
setIsToastOpen(true);
setLearnersAssignedCount(totalLearnersAssigned);
}, []);

const handleCloseToast = useCallback(() => {
setIsToastOpen(false);
}, []);

const successfulAssignmentAllocationToastMessage = `Course successfully assigned to ${learnersAssignedCount} ${learnersAssignedCount === 1 ? 'learner' : 'learners'}.`;

const successfulAssignmentToastContextValue = useMemo(() => ({
isSuccessfulAssignmentAllocationToastOpen: isToastOpen,
displayToastForAssignmentAllocation: handleDisplayToast,
closeToastForAssignmentAllocation: handleCloseToast,
totalLearnersAssigned: learnersAssignedCount,
successfulAssignmentAllocationToastMessage,
}), [
isToastOpen,
handleDisplayToast,
handleCloseToast,
learnersAssignedCount,
successfulAssignmentAllocationToastMessage,
]);

return successfulAssignmentToastContextValue;
};

export default useSuccessfulAssignmentToastContextValue;
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { useContext } from 'react';
import { Button } from '@edx/paragon';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import configureMockStore from 'redux-mock-store';
import { Provider } from 'react-redux';
import thunk from 'redux-thunk';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import '@testing-library/jest-dom/extend-expect';

import BudgetDetailPageWrapper, { BudgetDetailPageContext } from '../BudgetDetailPageWrapper';
import { getButtonElement } from '../../test/testUtils';

const mockStore = configureMockStore([thunk]);
const getMockStore = store => mockStore(store);
const enterpriseSlug = 'test-enterprise';
const enterpriseUUID = '1234';
const defaultStoreState = {
portalConfiguration: {
enterpriseId: enterpriseUUID,
enterpriseSlug,
enableLearnerPortal: true,
enterpriseFeatures: {
topDownAssignmentRealTimeLcm: true,
},
},
};

const MockBudgetDetailPageWrapper = ({
initialStoreState = defaultStoreState,
children,
}) => {
const store = getMockStore(initialStoreState);
return (
<IntlProvider locale="en">
<Provider store={store}>
<BudgetDetailPageWrapper>
{children}
</BudgetDetailPageWrapper>
</Provider>
</IntlProvider>
);
};

describe('<BudgetDetailPageWrapper />', () => {
it('should render its children and display hero by default', () => {
render(<MockBudgetDetailPageWrapper><div>hello world</div></MockBudgetDetailPageWrapper>);
// Verify children are rendered
expect(screen.getByText('hello world')).toBeInTheDocument();
// Verify Hero is rendered with the expected page title
expect(screen.getByText('Learner Credit Management')).toBeInTheDocument();
});

it.each([
{ totalLearnersAssigned: 1, expectedLearnerString: 'learner' },
{ totalLearnersAssigned: 2, expectedLearnerString: 'learners' },
])('should render Toast notification for successful assignment allocation (%s)', async ({
totalLearnersAssigned,
expectedLearnerString,
}) => {
const ToastContextController = () => {
const {
displayToastForAssignmentAllocation,
closeToastForAssignmentAllocation,
} = useContext(BudgetDetailPageContext);

const handleDisplayToast = () => {
displayToastForAssignmentAllocation({ totalLearnersAssigned });
};

const handleCloseToast = () => {
closeToastForAssignmentAllocation();
};

return (
<div>
<Button onClick={handleDisplayToast}>Open Toast</Button>
<Button onClick={handleCloseToast}>Close Toast</Button>
</div>
);
};
render(<MockBudgetDetailPageWrapper><ToastContextController /></MockBudgetDetailPageWrapper>);

const expectedToastMessage = `Course successfully assigned to ${totalLearnersAssigned} ${expectedLearnerString}.`;

// Open Toast notification
userEvent.click(getButtonElement('Open Toast'));

// Verify Toast notification is rendered
expect(screen.getByText(expectedToastMessage)).toBeInTheDocument();

// Close Toast notification
userEvent.click(getButtonElement('Close Toast'));

// Verify Toast notification is no longer rendered
await waitFor(() => {
expect(screen.queryByText(expectedToastMessage)).not.toBeInTheDocument();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { QueryClientProvider } from '@tanstack/react-query';
import { BaseCatalogSearchResults } from '../search/CatalogSearchResults';
import { CONTENT_TYPE_COURSE } from '../data/constants';
import { queryClient } from '../../test/testUtils';
import { useSubsidyAccessPolicy } from '../data';
import { BudgetDetailPageContext } from '../BudgetDetailPageWrapper';

// Mocking this connected component so as not to have to mock the algolia Api
const PAGINATE_ME = 'PAGINATE ME :)';
Expand Down Expand Up @@ -170,10 +170,18 @@ describe('Main Catalogs view works as expected', () => {
});

test('all courses rendered when search results available', async () => {
const budgetDetailPageContextValue = {
isSuccessfulAssignmentAllocationToastOpen: false,
totalLearnersAssigned: undefined,
displayToastForAssignmentAllocation: jest.fn(),
closeToastForAssignmentAllocation: jest.fn(),
};
renderWithRouter(
<SearchDataWrapper>
<IntlProvider locale="en">
<BaseCatalogSearchResults {...defaultProps} />
<BudgetDetailPageContext.Provider value={budgetDetailPageContextValue}>
<BaseCatalogSearchResults {...defaultProps} />
</BudgetDetailPageContext.Provider>
</IntlProvider>
,
</SearchDataWrapper>,
Expand Down

0 comments on commit d518a4d

Please sign in to comment.