From 1a537e423b35bf50bf3108a1ab6867defe83db72 Mon Sep 17 00:00:00 2001 From: Katrina Nguyen <71999631+katrinan029@users.noreply.github.com> Date: Wed, 4 Dec 2024 09:59:35 -0800 Subject: [PATCH 1/3] feat: add members data table to group detail page (#1350) * feat: add members data table to group detail page --- .../EnrollmentsTableColumnHeader.jsx | 40 +++++ .../PeopleManagement/GroupDetailPage.jsx | 33 +++- .../PeopleManagement/GroupMembersTable.jsx | 141 ++++++++++++++++++ .../RecentActionTableCell.jsx | 19 +++ src/components/PeopleManagement/constants.js | 3 + .../tests/GroupDetailPage.test.jsx | 45 +++++- .../data/hooks/index.js | 1 + ...eEnterpriseGroupLearnersTableData.test.jsx | 45 ++++++ .../useEnterpriseGroupLearnersTableData.js | 69 +++++++++ 9 files changed, 391 insertions(+), 5 deletions(-) create mode 100644 src/components/PeopleManagement/EnrollmentsTableColumnHeader.jsx create mode 100644 src/components/PeopleManagement/GroupMembersTable.jsx create mode 100644 src/components/PeopleManagement/RecentActionTableCell.jsx create mode 100644 src/components/learner-credit-management/data/hooks/tests/useEnterpriseGroupLearnersTableData.test.jsx create mode 100644 src/components/learner-credit-management/data/hooks/useEnterpriseGroupLearnersTableData.js diff --git a/src/components/PeopleManagement/EnrollmentsTableColumnHeader.jsx b/src/components/PeopleManagement/EnrollmentsTableColumnHeader.jsx new file mode 100644 index 000000000..939b0e0b7 --- /dev/null +++ b/src/components/PeopleManagement/EnrollmentsTableColumnHeader.jsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { + OverlayTrigger, + Tooltip, + Stack, + Icon, +} from '@openedx/paragon'; +import { InfoOutline } from '@openedx/paragon/icons'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; + +const EnrollmentsTableColumnHeader = () => ( + + + + + +
+ +
+ + )} + > + +
+
+); + +export default EnrollmentsTableColumnHeader; diff --git a/src/components/PeopleManagement/GroupDetailPage.jsx b/src/components/PeopleManagement/GroupDetailPage.jsx index 9f02b81e9..d47f54303 100644 --- a/src/components/PeopleManagement/GroupDetailPage.jsx +++ b/src/components/PeopleManagement/GroupDetailPage.jsx @@ -6,11 +6,12 @@ import { } from '@openedx/paragon'; import { Delete, Edit } from '@openedx/paragon/icons'; -import { useEnterpriseGroupUuid } from '../learner-credit-management/data'; +import { useEnterpriseGroupLearnersTableData, useEnterpriseGroupUuid } from '../learner-credit-management/data'; import { ROUTE_NAMES } from '../EnterpriseApp/data/constants'; import DeleteGroupModal from './DeleteGroupModal'; import EditGroupNameModal from './EditGroupNameModal'; import formatDates from './utils'; +import GroupMembersTable from './GroupMembersTable'; const GroupDetailPage = () => { const intl = useIntl(); @@ -20,7 +21,11 @@ const GroupDetailPage = () => { const [isEditModalOpen, openEditModal, closeEditModal] = useToggle(false); const [isLoading, setIsLoading] = useState(true); const [groupName, setGroupName] = useState(enterpriseGroup?.name); - + const { + isLoading: isTableLoading, + enterpriseGroupLearnersTableData, + fetchEnterpriseGroupLearnersTableData, + } = useEnterpriseGroupLearnersTableData({ groupUuid }); const handleNameUpdate = (name) => { setGroupName(name); }; @@ -119,7 +124,29 @@ const GroupDetailPage = () => { - ) : } + ) : } +
+

+ +

+

+ +

+
+ ); }; diff --git a/src/components/PeopleManagement/GroupMembersTable.jsx b/src/components/PeopleManagement/GroupMembersTable.jsx new file mode 100644 index 000000000..be59163a0 --- /dev/null +++ b/src/components/PeopleManagement/GroupMembersTable.jsx @@ -0,0 +1,141 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + DataTable, Dropdown, Icon, IconButton, +} from '@openedx/paragon'; +import { MoreVert, RemoveCircle } from '@openedx/paragon/icons'; +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import TableTextFilter from '../learner-credit-management/TableTextFilter'; +import CustomDataTableEmptyState from '../learner-credit-management/CustomDataTableEmptyState'; +import MemberDetailsTableCell from '../learner-credit-management/members-tab/MemberDetailsTableCell'; +import EnrollmentsTableColumnHeader from './EnrollmentsTableColumnHeader'; +import { GROUP_MEMBERS_TABLE_DEFAULT_PAGE, GROUP_MEMBERS_TABLE_PAGE_SIZE } from './constants'; +import RecentActionTableCell from './RecentActionTableCell'; + +const FilterStatus = (rest) => ; + +const KabobMenu = () => ( + + + + + + + + + +); + +const selectColumn = { + id: 'selection', + Header: DataTable.ControlledSelectHeader, + Cell: DataTable.ControlledSelect, + disableSortBy: true, +}; + +const GroupMembersTable = ({ + isLoading, + tableData, + fetchTableData, + groupUuid, +}) => { + const intl = useIntl(); + return ( + + row.original.enrollments, + disableFilters: true, + }, + ]} + initialTableOptions={{ + getRowId: row => row?.memberDetails.userEmail, + autoResetPage: true, + }} + initialState={{ + pageSize: GROUP_MEMBERS_TABLE_PAGE_SIZE, + pageIndex: GROUP_MEMBERS_TABLE_DEFAULT_PAGE, + sortBy: [ + { id: 'memberDetails', desc: true }, + ], + filters: [], + }} + additionalColumns={[ + { + id: 'action', + Header: '', + // eslint-disable-next-line react/no-unstable-nested-components + Cell: (props) => ( + + ), + }, + ]} + fetchData={fetchTableData} + data={tableData.results} + itemCount={tableData.itemCount} + pageCount={tableData.pageCount} + EmptyTableComponent={CustomDataTableEmptyState} + /> + + ); +}; + +GroupMembersTable.propTypes = { + isLoading: PropTypes.bool.isRequired, + tableData: PropTypes.shape({ + results: PropTypes.arrayOf(PropTypes.shape({ + })), + itemCount: PropTypes.number.isRequired, + pageCount: PropTypes.number.isRequired, + }).isRequired, + fetchTableData: PropTypes.func.isRequired, + groupUuid: PropTypes.string.isRequired, +}; + +export default GroupMembersTable; diff --git a/src/components/PeopleManagement/RecentActionTableCell.jsx b/src/components/PeopleManagement/RecentActionTableCell.jsx new file mode 100644 index 000000000..cbc2fb75b --- /dev/null +++ b/src/components/PeopleManagement/RecentActionTableCell.jsx @@ -0,0 +1,19 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import formatDates from './utils'; + +const RecentActionTableCell = ({ + row, +}) => ( +
Added: {formatDates(row.original.activatedAt)}
+); + +RecentActionTableCell.propTypes = { + row: PropTypes.shape({ + original: PropTypes.shape({ + activatedAt: PropTypes.string.isRequired, + }).isRequired, + }).isRequired, +}; + +export default RecentActionTableCell; diff --git a/src/components/PeopleManagement/constants.js b/src/components/PeopleManagement/constants.js index abd76681b..2c8a9de50 100644 --- a/src/components/PeopleManagement/constants.js +++ b/src/components/PeopleManagement/constants.js @@ -3,3 +3,6 @@ export const MAX_LENGTH_GROUP_NAME = 60; export const GROUP_TYPE_BUDGET = 'budget'; export const GROUP_TYPE_FLEX = 'flex'; export const GROUP_DROPDOWN_TEXT = 'Select group'; + +export const GROUP_MEMBERS_TABLE_PAGE_SIZE = 10; +export const GROUP_MEMBERS_TABLE_DEFAULT_PAGE = 0; // `DataTable` uses zero-index array diff --git a/src/components/PeopleManagement/tests/GroupDetailPage.test.jsx b/src/components/PeopleManagement/tests/GroupDetailPage.test.jsx index 7e4125230..e00d2fbd9 100644 --- a/src/components/PeopleManagement/tests/GroupDetailPage.test.jsx +++ b/src/components/PeopleManagement/tests/GroupDetailPage.test.jsx @@ -5,9 +5,10 @@ import '@testing-library/jest-dom/extend-expect'; import thunk from 'redux-thunk'; import configureMockStore from 'redux-mock-store'; import { Provider } from 'react-redux'; +import userEvent from '@testing-library/user-event'; import { IntlProvider } from '@edx/frontend-platform/i18n'; -import { useEnterpriseGroupUuid } from '../../learner-credit-management/data'; +import { useEnterpriseGroupUuid, useEnterpriseGroupLearnersTableData } from '../../learner-credit-management/data'; import GroupDetailPage from '../GroupDetailPage'; import LmsApiService from '../../../data/services/LmsApiService'; @@ -25,6 +26,7 @@ const getMockStore = store => mockStore(store); jest.mock('../../learner-credit-management/data', () => ({ ...jest.requireActual('../../learner-credit-management/data'), useEnterpriseGroupUuid: jest.fn(), + useEnterpriseGroupLearnersTableData: jest.fn(), })); jest.mock('../../../data/services/LmsApiService'); jest.mock('react-router-dom', () => ({ @@ -60,11 +62,50 @@ describe('', () => { beforeEach(() => { useEnterpriseGroupUuid.mockReturnValue({ data: TEST_GROUP }); }); - it('renders the GroupDetailPage', () => { + it('renders the GroupDetailPage', async () => { + const mockFetchEnterpriseGroupLearnersTableData = jest.fn(); + useEnterpriseGroupLearnersTableData.mockReturnValue({ + fetchEnterpriseGroupLearnersTableData: mockFetchEnterpriseGroupLearnersTableData, + isLoading: false, + enterpriseGroupLearnersTableData: { + count: 1, + currentPage: 1, + next: null, + numPages: 1, + results: [{ + activatedAt: '2024-11-06T21:01:32.953901Z', + enterprise_group_membership_uuid: TEST_GROUP, + memberDetails: { + userEmail: 'test@2u.com', + userName: 'Test 2u', + }, + recentAction: 'Accepted: November 06, 2024', + status: 'accepted', + enrollments: 1, + }], + }, + }); render(); expect(screen.queryAllByText(TEST_GROUP.name)).toHaveLength(2); expect(screen.getByText('0 accepted members')).toBeInTheDocument(); expect(screen.getByText('View group progress')).toBeInTheDocument(); + expect(screen.getByText('Add and remove group members.')).toBeInTheDocument(); + expect(screen.getByText('Test 2u')).toBeInTheDocument(); + userEvent.click(screen.getByText('Member details')); + await waitFor(() => expect(mockFetchEnterpriseGroupLearnersTableData).toHaveBeenCalledWith({ + filters: [], + pageIndex: 0, + pageSize: 10, + sortBy: [{ desc: true, id: 'memberDetails' }], + })); + + userEvent.click(screen.getByTestId('members-table-enrollments-column-header')); + await waitFor(() => expect(mockFetchEnterpriseGroupLearnersTableData).toHaveBeenCalledWith({ + filters: [], + pageIndex: 0, + pageSize: 10, + sortBy: [{ desc: false, id: 'enrollmentCount' }], + })); }); it('edit flex group name', async () => { const spy = jest.spyOn(LmsApiService, 'updateEnterpriseGroup'); diff --git a/src/components/learner-credit-management/data/hooks/index.js b/src/components/learner-credit-management/data/hooks/index.js index 21e5e65f8..c8df3b788 100644 --- a/src/components/learner-credit-management/data/hooks/index.js +++ b/src/components/learner-credit-management/data/hooks/index.js @@ -23,4 +23,5 @@ export { default as useContentMetadata } from './useContentMetadata'; export { default as useEnterpriseRemovedGroupMembers } from './useEnterpriseRemovedGroupMembers'; export { default as useEnterpriseFlexGroups } from './useEnterpriseFlexGroups'; export { default as useGroupDropdownToggle } from './useGroupDropdownToggle'; +export { default as useEnterpriseGroupLearnersTableData } from './useEnterpriseGroupLearnersTableData'; export { default as useEnterpriseLearners } from './useEnterpriseLearners'; diff --git a/src/components/learner-credit-management/data/hooks/tests/useEnterpriseGroupLearnersTableData.test.jsx b/src/components/learner-credit-management/data/hooks/tests/useEnterpriseGroupLearnersTableData.test.jsx new file mode 100644 index 000000000..ec49d431e --- /dev/null +++ b/src/components/learner-credit-management/data/hooks/tests/useEnterpriseGroupLearnersTableData.test.jsx @@ -0,0 +1,45 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { camelCaseObject } from '@edx/frontend-platform/utils'; +import LmsApiService from '../../../../../data/services/LmsApiService'; +import { useEnterpriseGroupLearnersTableData } from '../..'; + +describe('useEnterpriseGroupLearnersTableData', () => { + it('should fetch and return enterprise learners', async () => { + const mockGroupUUID = 'test-uuid'; + const mockData = { + count: 1, + current_page: 1, + next: null, + num_pages: 1, + previous: null, + results: [{ + activated_at: '2024-11-06T21:01:32.953901Z', + enterprise_customer_user_id: 1, + enterprise_group_membership_uuid: 'test-uuid', + member_details: { + user_email: 'test@2u.com', + user_name: 'Test 2u', + }, + recent_action: 'Accepted: November 06, 2024', + status: 'accepted', + enrollments: 1, + }], + }; + const mockEnterpriseGroupLearners = jest.spyOn(LmsApiService, 'fetchEnterpriseGroupLearners'); + mockEnterpriseGroupLearners.mockResolvedValue({ data: mockData }); + + const { result, waitForNextUpdate } = renderHook( + () => useEnterpriseGroupLearnersTableData({ groupUuid: mockGroupUUID }), + ); + result.current.fetchEnterpriseGroupLearnersTableData({ + pageIndex: 0, + pageSize: 10, + filters: [], + sortBy: [], + }); + await waitForNextUpdate(); + expect(LmsApiService.fetchEnterpriseGroupLearners).toHaveBeenCalledWith(mockGroupUUID, { page: 1 }); + expect(result.current.isLoading).toEqual(false); + expect(result.current.enterpriseGroupLearnersTableData.results).toEqual(camelCaseObject(mockData.results)); + }); +}); diff --git a/src/components/learner-credit-management/data/hooks/useEnterpriseGroupLearnersTableData.js b/src/components/learner-credit-management/data/hooks/useEnterpriseGroupLearnersTableData.js new file mode 100644 index 000000000..2e5d6e926 --- /dev/null +++ b/src/components/learner-credit-management/data/hooks/useEnterpriseGroupLearnersTableData.js @@ -0,0 +1,69 @@ +import { + useCallback, useMemo, useState, +} from 'react'; +import _ from 'lodash'; +import { camelCaseObject } from '@edx/frontend-platform/utils'; +import { logError } from '@edx/frontend-platform/logging'; +import debounce from 'lodash.debounce'; + +import LmsApiService from '../../../../data/services/LmsApiService'; + +const useEnterpriseGroupLearnersTableData = ({ groupUuid }) => { + const [isLoading, setIsLoading] = useState(true); + const [enterpriseGroupLearnersTableData, setEnterpriseGroupLearnersTableData] = useState({ + itemCount: 0, + pageCount: 0, + results: [], + }); + const fetchEnterpriseGroupLearnersData = useCallback((args) => { + const fetch = async () => { + try { + setIsLoading(true); + const options = {}; + if (args?.sortBy.length > 0) { + const sortByValue = args.sortBy[0].id; + options.sort_by = _.snakeCase(sortByValue); + if (!args.sortBy[0].desc) { + options.is_reversed = !args.sortBy[0].desc; + } + } + args.filters.forEach((filter) => { + const { id, value } = filter; + if (id === 'status') { + options.show_removed = value; + } else if (id === 'memberDetails') { + options.user_query = value; + } + }); + + options.page = args.pageIndex + 1; + const response = await LmsApiService.fetchEnterpriseGroupLearners(groupUuid, options); + const data = camelCaseObject(response.data); + + setEnterpriseGroupLearnersTableData({ + itemCount: data.count, + pageCount: data.numPages ?? Math.floor(data.count / options.pageSize), + results: data.results.filter(result => result.activatedAt), + }); + } catch (error) { + logError(error); + } finally { + setIsLoading(false); + } + }; + fetch(); + }, [groupUuid]); + + const debouncedFetchEnterpriseGroupLearnersData = useMemo( + () => debounce(fetchEnterpriseGroupLearnersData, 300), + [fetchEnterpriseGroupLearnersData], + ); + + return { + isLoading, + enterpriseGroupLearnersTableData, + fetchEnterpriseGroupLearnersTableData: debouncedFetchEnterpriseGroupLearnersData, + }; +}; + +export default useEnterpriseGroupLearnersTableData; From 5918556f30c1f6a0e8816a29b2d1bbc4f8e7a750 Mon Sep 17 00:00:00 2001 From: Troy Sankey Date: Mon, 25 Nov 2024 21:59:01 -0800 Subject: [PATCH 2/3] feat: enable allocation of restricted runs ENT-9411 --- .../NewAssignmentModalButton.jsx | 30 +++- .../NewAssignmentModalDropdown.jsx | 51 +++--- .../cards/BaseCourseCard.jsx | 11 +- .../cards/data/useCourseCardMetadata.jsx | 21 ++- .../cards/tests/CourseCard.test.jsx | 152 ++++++++++++++++++ .../data/constants.js | 11 ++ .../data/hooks/index.js | 1 + ...ntainsContentItemsMultipleQueries.test.jsx | 71 ++++++++ ...alogContainsContentItemsMultipleQueries.js | 48 ++++++ .../learner-credit-management/data/utils.js | 23 ++- .../services/EnterpriseCatalogApiServiceV2.js | 34 ++++ src/index.jsx | 1 + 12 files changed, 420 insertions(+), 34 deletions(-) create mode 100644 src/components/learner-credit-management/data/hooks/tests/useCatalogContainsContentItemsMultipleQueries.test.jsx create mode 100644 src/components/learner-credit-management/data/hooks/useCatalogContainsContentItemsMultipleQueries.js create mode 100644 src/data/services/EnterpriseCatalogApiServiceV2.js diff --git a/src/components/learner-credit-management/assignment-modal/NewAssignmentModalButton.jsx b/src/components/learner-credit-management/assignment-modal/NewAssignmentModalButton.jsx index 63b6bfe8e..994b8ca68 100644 --- a/src/components/learner-credit-management/assignment-modal/NewAssignmentModalButton.jsx +++ b/src/components/learner-credit-management/assignment-modal/NewAssignmentModalButton.jsx @@ -16,12 +16,18 @@ import EnterpriseAccessApiService from '../../../data/services/EnterpriseAccessA import EVENT_NAMES from '../../../eventTracking'; import { BudgetDetailPageContext } from '../BudgetDetailPageWrapper'; import { - getAssignableCourseRuns, learnerCreditManagementQueryKeys, LEARNER_CREDIT_ROUTE, useBudgetId, - useSubsidyAccessPolicy, useEnterpriseFlexGroups, + getAssignableCourseRuns, + LEARNER_CREDIT_ROUTE, + learnerCreditManagementQueryKeys, + useBudgetId, + useCatalogContainsContentItemsMultipleQueries, + useEnterpriseFlexGroups, + useSubsidyAccessPolicy, } from '../data'; import AssignmentModalContent from './AssignmentModalContent'; import CreateAllocationErrorAlertModals from './CreateAllocationErrorAlertModals'; import NewAssignmentModalDropdown from './NewAssignmentModalDropdown'; +import { ENTERPRISE_RESTRICTION_TYPE } from '../data/constants'; const useAllocateContentAssignments = () => useMutation({ mutationFn: async ({ @@ -74,10 +80,23 @@ const NewAssignmentModalButton = ({ enterpriseId, course, children }) => { assignmentConfiguration, isLateRedemptionAllowed, }; + const { + dataByContentKey: catalogContainsRestrictedRunsData, + isLoading: isLoadingCatalogContainsRestrictedRuns, + } = useCatalogContainsContentItemsMultipleQueries( + catalogUuid, + course.courseRuns?.filter( + // Pass only restricted runs. + run => run.restrictionType === ENTERPRISE_RESTRICTION_TYPE, + ).map( + run => run.key, + ), + ); const assignableCourseRuns = getAssignableCourseRuns({ courseRuns: course.courseRuns, subsidyExpirationDatetime: subsidyAccessPolicy.subsidyExpirationDatetime, isLateRedemptionAllowed, + catalogContainsRestrictedRunsData, }); const { mutate } = useAllocateContentAssignments(); const pathToActivityTab = generatePath(LEARNER_CREDIT_ROUTE, { @@ -219,7 +238,12 @@ const NewAssignmentModalButton = ({ enterpriseId, course, children }) => { }; return ( <> - + {children} { const intl = useIntl(); const [clickedDropdownItem, setClickedDropdownItem] = useState(null); @@ -59,26 +60,35 @@ const NewAssignmentModalDropdown = ({ {courseRuns.length > 0 ? intl.formatMessage(messages.byDate) : intl.formatMessage(messages.noAvailableDates) } - {courseRuns.length > 0 && courseRuns.map(courseRun => ( - setClickedDropdownItem(courseRun)} - onMouseUp={() => setClickedDropdownItem(null)} - > - - {intl.formatMessage(messages.startDate, { - startLabel: startLabel(courseRun), - startDate: dayjs(courseRun.start).format(SHORT_MONTH_DATE_FORMAT), - })} - - {intl.formatMessage(messages.enrollBy, { - enrollByDate: dayjs(courseRun.enrollBy).format(SHORT_MONTH_DATE_FORMAT), + {courseRuns.length > 0 && courseRuns.map(courseRun => { + if (isLoading) { + return ( + + + + ); + } + return ( + setClickedDropdownItem(courseRun)} + onMouseUp={() => setClickedDropdownItem(null)} + > + + {intl.formatMessage(messages.startDate, { + startLabel: startLabel(courseRun), + startDate: dayjs(courseRun.start).format(SHORT_MONTH_DATE_FORMAT), })} - - - - ))} + + {intl.formatMessage(messages.enrollBy, { + enrollByDate: dayjs(courseRun.enrollBy).format(SHORT_MONTH_DATE_FORMAT), + })} + + + + ); + })} ); @@ -93,6 +103,7 @@ NewAssignmentModalDropdown.propTypes = { start: PropTypes.string, })).isRequired, children: PropTypes.node.isRequired, + isLoading: PropTypes.bool.isRequired, }; export default NewAssignmentModalDropdown; diff --git a/src/components/learner-credit-management/cards/BaseCourseCard.jsx b/src/components/learner-credit-management/cards/BaseCourseCard.jsx index a9545d8e8..6286e7dd3 100644 --- a/src/components/learner-credit-management/cards/BaseCourseCard.jsx +++ b/src/components/learner-credit-management/cards/BaseCourseCard.jsx @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import { - Badge, breakpoints, Card, Stack, useMediaQuery, + Badge, breakpoints, Card, Skeleton, Stack, useMediaQuery, } from '@openedx/paragon'; import { camelCaseObject } from '@edx/frontend-platform/utils'; @@ -35,7 +35,14 @@ const BaseCourseCard = ({ formattedPrice, isExecEdCourseType, footerText, + isLoadingCatalogContainsRestrictedRuns, } = courseCardMetadata; + const coursePrice = ( + isLoadingCatalogContainsRestrictedRuns + ? + : formattedPrice + ); + const cardPrice = courseRun ? formatPrice(courseRun.contentPrice) : coursePrice; return ( -
{courseRun ? formatPrice(courseRun.contentPrice) : formattedPrice}
+
{cardPrice}
run.restrictionType === ENTERPRISE_RESTRICTION_TYPE, + ).map( + run => run.key, + ), + ); + const assignableCourseRuns = getAssignableCourseRuns({ courseRuns, subsidyExpirationDatetime: subsidyAccessPolicy.subsidyExpirationDatetime, isLateRedemptionAllowed: subsidyAccessPolicy.isLateRedemptionAllowed, + catalogContainsRestrictedRunsData, }); // Extracts the content price from assignable course runs @@ -82,7 +98,7 @@ const useCourseCardMetadata = ({ } const footerText = intl.formatMessage(messages.courseFooterMessage, { - courseRuns: assignableCourseRuns.length, + numCourseRuns: assignableCourseRuns.length, pluralText: pluralText('date', assignableCourseRuns.length), }); @@ -98,6 +114,7 @@ const useCourseCardMetadata = ({ linkToCourse, isExecEdCourseType, footerText, + isLoadingCatalogContainsRestrictedRuns, }; }; diff --git a/src/components/learner-credit-management/cards/tests/CourseCard.test.jsx b/src/components/learner-credit-management/cards/tests/CourseCard.test.jsx index d2fb046dc..389ff0af6 100644 --- a/src/components/learner-credit-management/cards/tests/CourseCard.test.jsx +++ b/src/components/learner-credit-management/cards/tests/CourseCard.test.jsx @@ -6,6 +6,7 @@ import { Provider } from 'react-redux'; import thunk from 'redux-thunk'; import configureMockStore from 'redux-mock-store'; import { QueryClientProvider, useQueryClient } from '@tanstack/react-query'; +import { getConfig } from '@edx/frontend-platform'; import { AppContext } from '@edx/frontend-platform/react'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import { renderWithRouter, sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils'; @@ -20,6 +21,7 @@ import { useBudgetId, useSubsidyAccessPolicy, useEnterpriseFlexGroups, + useCatalogContainsContentItemsMultipleQueries, } from '../../data'; import { getButtonElement, queryClient } from '../../../test/testUtils'; @@ -27,6 +29,11 @@ import EnterpriseAccessApiService from '../../../../data/services/EnterpriseAcce import { BudgetDetailPageContext } from '../../BudgetDetailPageWrapper'; import { EMAIL_ADDRESSES_INPUT_VALUE_DEBOUNCE_DELAY } from '../data'; import { getGroupMemberEmails } from '../../data/hooks/useEnterpriseFlexGroups'; +import { ENTERPRISE_RESTRICTION_TYPE } from '../../data/constants'; + +jest.mock('@edx/frontend-platform', () => ({ + getConfig: jest.fn(() => ({})), +})); jest.mock('@edx/frontend-enterprise-utils', () => ({ ...jest.requireActual('@edx/frontend-enterprise-utils'), @@ -51,6 +58,7 @@ jest.mock('../../data', () => ({ useBudgetId: jest.fn(), useSubsidyAccessPolicy: jest.fn(), useEnterpriseFlexGroups: jest.fn(), + useCatalogContainsContentItemsMultipleQueries: jest.fn(), })); jest.mock('../../data/hooks/useEnterpriseFlexGroups'); jest.mock('../../../../data/services/EnterpriseAccessApiService'); @@ -286,6 +294,11 @@ describe('Course card works as expected', () => { useEnterpriseFlexGroups.mockReturnValue({ data: mockEnterpriseFlexGroup, }); + useCatalogContainsContentItemsMultipleQueries.mockReturnValue({ + data: {}, + dataByContentKey: {}, + isLoading: false, + }); }); afterEach(() => { @@ -861,4 +874,143 @@ describe('Course card works as expected', () => { expect(assignmentModal.getByText('dinesh@example.com')).toBeInTheDocument(); }); }); + + test.each([ + // The "pure" case, i.e. course contains only unrestricted runs. + { + runs: [ + originalData.courseRuns[0], + ], + containsContentItemsMockDataByContentKey: {}, + containsContentItemsIsLoading: false, + expectedCoursePriceSkeleton: false, + expectedNumRunSkeletons: 0, + expectedAssignableEnrollByDates: [ + originalData.courseRuns[0].enroll_by, + ], + }, + // The "mixed" case, i.e. course contains both restricted and unrestricted runs. + { + runs: [ + originalData.courseRuns[0], + { + ...originalData.courseRuns[0], + restrictionType: ENTERPRISE_RESTRICTION_TYPE, + key: 'course-v1:edX+course-123x+3T2020.restricted', + start: dayjs(futureStartDate).add(10, 'days').toISOString(), + enroll_by: dayjs.unix(enrollByTimestamp).add(10, 'days').unix(), + enroll_start: dayjs.unix(enrollStartTimestamp).add(10, 'days').unix(), + content_price: '100', + }, + ], + containsContentItemsMockDataByContentKey: { + 'course-v1:edX+course-123x+3T2020.restricted': { containsContentItems: true }, + }, + containsContentItemsIsLoading: false, + expectedCoursePriceSkeleton: false, + expectedNumRunSkeletons: 0, + expectedAssignableEnrollByDates: [ + originalData.courseRuns[0].enroll_by, + dayjs.unix(enrollByTimestamp).add(10, 'days').unix(), + ], + }, + // The "unicorn course" case, i.e. course contains only restricted runs. + { + runs: [ + { + ...originalData.courseRuns[0], + restrictionType: ENTERPRISE_RESTRICTION_TYPE, + key: 'course-v1:edX+course-123x+3T2020.restricted', + start: dayjs(futureStartDate).add(10, 'days').toISOString(), + enroll_by: dayjs.unix(enrollByTimestamp).add(10, 'days').unix(), + enroll_start: dayjs.unix(enrollStartTimestamp).add(10, 'days').unix(), + content_price: '100', + }, + ], + containsContentItemsMockDataByContentKey: { + 'course-v1:edX+course-123x+3T2020.restricted': { containsContentItems: true }, + }, + containsContentItemsIsLoading: false, + expectedCoursePriceSkeleton: false, + expectedNumRunSkeletons: 0, + expectedAssignableEnrollByDates: [ + dayjs.unix(enrollByTimestamp).add(10, 'days').unix(), + ], + }, + // Ensure skeletons appear when the contains_content_items API calls are still loading. + { + runs: [ + originalData.courseRuns[0], + { + ...originalData.courseRuns[0], + restrictionType: ENTERPRISE_RESTRICTION_TYPE, + key: 'course-v1:edX+course-123x+3T2020.restricted', + start: dayjs(futureStartDate).add(10, 'days').toISOString(), + enroll_by: dayjs.unix(enrollByTimestamp).add(10, 'days').unix(), + enroll_start: dayjs.unix(enrollStartTimestamp).add(10, 'days').unix(), + content_price: '100', + }, + ], + containsContentItemsMockDataByContentKey: { + // undefined is meant to simulate that data is still loading. + 'course-v1:edX+course-123x+3T2020.restricted': undefined, + }, + containsContentItemsIsLoading: true, + expectedCoursePriceSkeleton: true, + // The number of run skeletons that appear in the Assign drop-down should + // be equal to the number of _unrestricted_ runs. getAssignableCourseRuns + // initially won't assume that the restricted runs are assignable, so + // won't return them to be counted. + expectedNumRunSkeletons: 1, + expectedAssignableEnrollByDates: [], + }, + ])('course card renders assignable restricted runs (%s)', async ({ + runs, + containsContentItemsMockDataByContentKey, + containsContentItemsIsLoading, + expectedCoursePriceSkeleton, + expectedNumRunSkeletons, + expectedAssignableEnrollByDates, + }) => { + getConfig.mockReturnValue({ + FEATURE_ENABLE_RESTRICTED_RUN_ASSIGNMENT: true, + }); + const data = { + ...originalData, + courseRuns: runs, + advertised_course_run: runs[0], + normalized_metadata: { + enroll_by_date: dayjs.unix(runs[0].upgrade_deadline).toISOString(), + start_date: runs[0].start, + enroll_start_date: enrollStartDate, + content_price: runs[0].content_price, + }, + }; + const props = { + original: data, + }; + useCatalogContainsContentItemsMultipleQueries.mockReturnValue({ + dataByContentKey: containsContentItemsMockDataByContentKey, + isLoading: containsContentItemsIsLoading, + }); + + renderWithRouter(); + if (expectedCoursePriceSkeleton) { + expect(screen.queryByTestId('course-price-skeleton')).toBeInTheDocument(); + userEvent.click(screen.getByText('Assign')); + await waitFor(() => { + expect(screen.queryAllByTestId('assignment-dropdown-item-skeleton').length).toBe(expectedNumRunSkeletons); + }); + } else { + expect(screen.queryByTestId('course-price-skeleton')).not.toBeInTheDocument(); + userEvent.click(screen.getByText('Assign')); + await waitFor(() => { + expectedAssignableEnrollByDates.forEach((enrollByDate) => { + expect(screen.getByText( + `Enroll by ${dayjs.unix(enrollByDate).format(SHORT_MONTH_DATE_FORMAT)}`, + )).toBeInTheDocument(); + }); + }); + } + }); }); diff --git a/src/components/learner-credit-management/data/constants.js b/src/components/learner-credit-management/data/constants.js index abc7e43c4..195b3e7b9 100644 --- a/src/components/learner-credit-management/data/constants.js +++ b/src/components/learner-credit-management/data/constants.js @@ -125,7 +125,18 @@ export const learnerCreditManagementQueryKeys = { budgetGroupLearners: (budgetId) => [...learnerCreditManagementQueryKeys.budget(budgetId), 'group learners'], enterpriseCustomer: (enterpriseId) => [...learnerCreditManagementQueryKeys.all, 'enterpriseCustomer', enterpriseId], flexGroup: (enterpriseId) => [...learnerCreditManagementQueryKeys.enterpriseCustomer(enterpriseId), 'flexGroup'], + catalog: (catalog) => [...learnerCreditManagementQueryKeys.all, 'catalog', catalog], + catalogContainsContentItem: (catalogUuid, contentKey) => [ + ...learnerCreditManagementQueryKeys.catalog(catalogUuid), + 'containsContentItem', + contentKey, + ], }; // Route to learner credit export const LEARNER_CREDIT_ROUTE = '/:enterpriseSlug/admin/:enterpriseAppPage/:budgetId/:activeTabKey?'; + +// [ENT-9359] Restricted runs/custom presentations. +// The `restriction_type` metadata key for course runs may have this value, +// indicating that the run is restricted. +export const ENTERPRISE_RESTRICTION_TYPE = 'custom-b2b-enterprise'; diff --git a/src/components/learner-credit-management/data/hooks/index.js b/src/components/learner-credit-management/data/hooks/index.js index c8df3b788..cdd1006dd 100644 --- a/src/components/learner-credit-management/data/hooks/index.js +++ b/src/components/learner-credit-management/data/hooks/index.js @@ -25,3 +25,4 @@ export { default as useEnterpriseFlexGroups } from './useEnterpriseFlexGroups'; export { default as useGroupDropdownToggle } from './useGroupDropdownToggle'; export { default as useEnterpriseGroupLearnersTableData } from './useEnterpriseGroupLearnersTableData'; export { default as useEnterpriseLearners } from './useEnterpriseLearners'; +export { default as useCatalogContainsContentItemsMultipleQueries } from './useCatalogContainsContentItemsMultipleQueries'; diff --git a/src/components/learner-credit-management/data/hooks/tests/useCatalogContainsContentItemsMultipleQueries.test.jsx b/src/components/learner-credit-management/data/hooks/tests/useCatalogContainsContentItemsMultipleQueries.test.jsx new file mode 100644 index 000000000..45d730bb6 --- /dev/null +++ b/src/components/learner-credit-management/data/hooks/tests/useCatalogContainsContentItemsMultipleQueries.test.jsx @@ -0,0 +1,71 @@ +import { QueryClientProvider } from '@tanstack/react-query'; +import { renderHook } from '@testing-library/react-hooks/dom'; +import { v4 as uuidv4 } from 'uuid'; + +import useCatalogContainsContentItemsMultipleQueries from '../useCatalogContainsContentItemsMultipleQueries'; +import EnterpriseCatalogApiServiceV2 from '../../../../../data/services/EnterpriseCatalogApiServiceV2'; +import { queryClient } from '../../../../test/testUtils'; + +const TEST_CATALOG_UUID = uuidv4(); +const courseRunKeys = [ + 'course-v1:edX+test+course.1', + 'course-v1:edX+test+course.2', +]; + +jest.mock('../../../../../data/services/EnterpriseCatalogApiServiceV2'); + +const wrapper = ({ children }) => ( + {children} +); + +describe('useCatalogContainsContentItemsMultipleQueries', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should fetch contains_content_items for requested restricted runs', async () => { + EnterpriseCatalogApiServiceV2.retrieveContainsContentItems + .mockResolvedValueOnce({ data: { foo: 'bar' } }) + .mockResolvedValueOnce({ data: { bin: 'baz' } }); + + const { result, waitForNextUpdate } = renderHook( + () => useCatalogContainsContentItemsMultipleQueries( + TEST_CATALOG_UUID, + courseRunKeys, + ), + { wrapper }, + ); + + expect(result.current).toMatchObject({ + data: [undefined, undefined], + dataByContentKey: { + 'course-v1:edX+test+course.1': undefined, + 'course-v1:edX+test+course.2': undefined, + }, + isLoading: true, + isFetching: true, + isError: false, + errorByContentKey: { + 'course-v1:edX+test+course.1': null, + 'course-v1:edX+test+course.2': null, + }, + }); + + await waitForNextUpdate(); + + expect(result.current).toMatchObject({ + data: [{ foo: 'bar' }, { bin: 'baz' }], + dataByContentKey: { + 'course-v1:edX+test+course.1': { foo: 'bar' }, + 'course-v1:edX+test+course.2': { bin: 'baz' }, + }, + isLoading: false, + isFetching: false, + isError: false, + errorByContentKey: { + 'course-v1:edX+test+course.1': null, + 'course-v1:edX+test+course.2': null, + }, + }); + }); +}); diff --git a/src/components/learner-credit-management/data/hooks/useCatalogContainsContentItemsMultipleQueries.js b/src/components/learner-credit-management/data/hooks/useCatalogContainsContentItemsMultipleQueries.js new file mode 100644 index 000000000..3ce35d6e5 --- /dev/null +++ b/src/components/learner-credit-management/data/hooks/useCatalogContainsContentItemsMultipleQueries.js @@ -0,0 +1,48 @@ +import { useQueries } from '@tanstack/react-query'; +import { camelCaseObject } from '@edx/frontend-platform/utils'; + +import EnterpriseCatalogApiServiceV2 from '../../../../data/services/EnterpriseCatalogApiServiceV2'; +import { learnerCreditManagementQueryKeys } from '../constants'; + +/** + * Retrieves a response from the following enterprise-catalog endpoint for a SINGLE content key: + * + * /api/v2/enterprise-catalogs/{uuid}/contains_content_items/?course_run_ids={content_key} + * + * @param {*} queryKey The queryKey from the associated `useQuery` call. + * @returns The contains_content_items response. + */ +const getCatalogContainsContentItem = async ({ queryKey }) => { + const catalogUuid = queryKey[2]; + const contentKey = queryKey[4]; + const response = await EnterpriseCatalogApiServiceV2.retrieveContainsContentItems(catalogUuid, contentKey); + return camelCaseObject(response.data); +}; + +const useCatalogContainsContentItemsMultipleQueries = (catalogUuid, contentKeys = [], { queryOptions } = {}) => { + const multipleResults = useQueries({ + queries: contentKeys.map((contentKey) => ({ + queryKey: learnerCreditManagementQueryKeys.catalogContainsContentItem(catalogUuid, contentKey), + queryFn: getCatalogContainsContentItem, + enabled: !!catalogUuid, + ...queryOptions, + })), + }); + return { + data: multipleResults.map(result => result.data), + // Reproduce the above results, but in a form that is more convenient for + // consumers. This only works because we can safely assume the results + // from useQueries are ordered the same as its inputs. + dataByContentKey: Object.fromEntries(multipleResults.map((result, i) => [contentKeys[i], result.data])), + // This whole hook is considered to be still loading if at least one query + // is still loading, implying either that the upstream waterfall query to + // fetch the policy has not yet returned, or at least one call to + // contains-content-items is still being requested. + isLoading: multipleResults.length !== 0 && multipleResults.some(result => result.isLoading), + isFetching: multipleResults.length !== 0 && multipleResults.some(result => result.isFetching), + isError: multipleResults.length !== 0 && multipleResults.some(result => result.isError), + errorByContentKey: Object.fromEntries(multipleResults.map((result, i) => [contentKeys[i], result.error])), + }; +}; + +export default useCatalogContainsContentItemsMultipleQueries; diff --git a/src/components/learner-credit-management/data/utils.js b/src/components/learner-credit-management/data/utils.js index 52a750c05..2ce61d211 100644 --- a/src/components/learner-credit-management/data/utils.js +++ b/src/components/learner-credit-management/data/utils.js @@ -1,3 +1,4 @@ +import { getConfig } from '@edx/frontend-platform'; import { logInfo } from '@edx/frontend-platform/logging'; import { camelCaseObject } from '@edx/frontend-platform/utils'; import dayjs from 'dayjs'; @@ -688,7 +689,11 @@ export const startAndEnrollBySortLogic = (prev, next) => { * @param isLateRedemptionAllowed * @returns {*} */ -export const getAssignableCourseRuns = ({ courseRuns, subsidyExpirationDatetime, isLateRedemptionAllowed }) => { +export const getAssignableCourseRuns = ({ + courseRuns, subsidyExpirationDatetime, + isLateRedemptionAllowed, + catalogContainsRestrictedRunsData, +}) => { const clonedCourseRuns = courseRuns.map(courseRun => ({ ...courseRun, enrollBy: courseRun.hasEnrollBy ? dayjs.unix(courseRun.enrollBy).toISOString() : null, @@ -697,7 +702,7 @@ export const getAssignableCourseRuns = ({ courseRuns, subsidyExpirationDatetime, })); const assignableCourseRunsFilter = ({ - enrollBy, enrollStart, start, hasEnrollBy, hasEnrollStart, isActive, isLateEnrollmentEligible, restrictionType, + key, enrollBy, enrollStart, start, hasEnrollBy, hasEnrollStart, isActive, isLateEnrollmentEligible, restrictionType, }) => { const isEnrollByDateValid = isEnrollByDateWithinThreshold({ hasEnrollBy, @@ -720,12 +725,16 @@ export const getAssignableCourseRuns = ({ courseRuns, subsidyExpirationDatetime, return false; } // ENT-9359 (epic for Custom Presentations/Restricted Runs): - // Temporarily hide all restricted runs unconditionally on the run assignment - // dropdown during implementation of the overall feature. ENT-9411 is most likely - // the ticket to replace this code with something to actually show restricted - // runs conditionally. + // Hide any restricted runs that are not considered to be "contained" in the policy's catalog. if (restrictionType) { - return false; + // Always filter out restricted runs if the feature to show them isn't even enabled. + if (!getConfig().FEATURE_ENABLE_RESTRICTED_RUN_ASSIGNMENT) { + return false; + } + // Only filter out restricted runs if the run isn't part of the policy's catalog. + if (!catalogContainsRestrictedRunsData?.[key]?.containsContentItems) { + return false; + } } if (hasEnrollBy && isLateRedemptionAllowed && isDateBeforeToday(enrollBy)) { // Special case: late enrollment has been enabled by ECS for this budget, and diff --git a/src/data/services/EnterpriseCatalogApiServiceV2.js b/src/data/services/EnterpriseCatalogApiServiceV2.js new file mode 100644 index 000000000..ae7199355 --- /dev/null +++ b/src/data/services/EnterpriseCatalogApiServiceV2.js @@ -0,0 +1,34 @@ +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +import { configuration } from '../../config'; + +class EnterpriseCatalogApiServiceV2 { + static baseUrl = `${configuration.ENTERPRISE_CATALOG_BASE_URL}/api/v2`; + + static apiClient = getAuthenticatedHttpClient; + + static enterpriseCatalogsUrl = `${EnterpriseCatalogApiServiceV2.baseUrl}/enterprise-catalogs/`; + + /** + * Retrieves the enterprise-catalog based contains_content_items endpoint for + * ONE content key: + * + * /api/v2/enterprise-catalogs/{uuid}/contains_content_items/?course_run_ids={content_key} + * + * This endpoint technically supports an arbitrary number of content keys, + * but this function only supports one. + * + * @param {*} catalogUuid The catalog to check for content inclusion. + * @param {*} contentKey The content to check for inclusion in the requested catalog. + */ + static retrieveContainsContentItems(catalogUuid, contentKey) { + const queryParams = new URLSearchParams(); + queryParams.append('course_run_ids', contentKey); + const baseCatalogUrl = `${EnterpriseCatalogApiServiceV2.enterpriseCatalogsUrl}${catalogUuid}`; + return EnterpriseCatalogApiServiceV2.apiClient().get( + `${baseCatalogUrl}/contains_content_items/?${queryParams.toString()}`, + ); + } +} + +export default EnterpriseCatalogApiServiceV2; diff --git a/src/index.jsx b/src/index.jsx index 814b110d0..788184218 100644 --- a/src/index.jsx +++ b/src/index.jsx @@ -42,6 +42,7 @@ initialize({ ENTERPRISE_SUPPORT_PROGRAM_OPTIMIZATION_URL: process.env.ENTERPRISE_SUPPORT_PROGRAM_OPTIMIZATION_URL || null, ENTERPRISE_SUPPORT_LEARNER_CREDIT_URL: process.env.ENTERPRISE_SUPPORT_LEARNER_CREDIT_URL || null, EDX_ACCESS_URL: process.env.EDX_ACCESS_URL || null, + FEATURE_ENABLE_RESTRICTED_RUN_ASSIGNMENT: process.env.FEATURE_ENABLE_RESTRICTED_RUN_ASSIGNMENT || null, }); }, }, From 9eda274e150e4b962af1d660d2baba6d946fa193 Mon Sep 17 00:00:00 2001 From: Troy Sankey Date: Tue, 3 Dec 2024 10:45:10 -0800 Subject: [PATCH 3/3] test: extend timeout on slow test --- .../members-tab/tests/MembersTab.test.jsx | 206 +++++++++--------- 1 file changed, 107 insertions(+), 99 deletions(-) diff --git a/src/components/learner-credit-management/members-tab/tests/MembersTab.test.jsx b/src/components/learner-credit-management/members-tab/tests/MembersTab.test.jsx index d7ce81e19..750d43f46 100644 --- a/src/components/learner-credit-management/members-tab/tests/MembersTab.test.jsx +++ b/src/components/learner-credit-management/members-tab/tests/MembersTab.test.jsx @@ -769,107 +769,115 @@ describe('', () => { ['foobar@test.com'], ); }); - it('test member status popovers', async () => { - const initialState = { - portalConfiguration: { - ...initialStoreState.portalConfiguration, - enterpriseFeatures: { - enterpriseGroupsV1: true, + it( + 'test member status popovers', + async () => { + const initialState = { + portalConfiguration: { + ...initialStoreState.portalConfiguration, + enterpriseFeatures: { + enterpriseGroupsV1: true, + }, }, - }, - }; - useParams.mockReturnValue({ - enterpriseSlug: 'test-enterprise-slug', - enterpriseAppPage: 'test-enterprise-page', - activeTabKey: 'members', - }); - useSubsidyAccessPolicy.mockReturnValue({ - isInitialLoading: false, - data: mockAssignableSubsidyAccessPolicy, - }); - useBudgetDetailActivityOverview.mockReturnValue({ - isLoading: false, - data: mockEmptyStateBudgetDetailActivityOverview, - }); - useBudgetRedemptions.mockReturnValue({ - isLoading: false, - budgetRedemptions: mockEmptyBudgetRedemptions, - fetchBudgetRedemptions: jest.fn(), - }); - useEnterpriseGroupLearners.mockReturnValue({ - data: { - count: 1, - currentPage: 1, - next: null, - numPages: 1, - results: { - enterpriseGroupMembershipUuid: 'cde2e374-032f-4c08-8c0d-bf3205fa7c7e', - learnerId: 4382, - memberDetails: { userEmail: 'dukesilver@test.com', userName: 'duke silver' }, + }; + useParams.mockReturnValue({ + enterpriseSlug: 'test-enterprise-slug', + enterpriseAppPage: 'test-enterprise-page', + activeTabKey: 'members', + }); + useSubsidyAccessPolicy.mockReturnValue({ + isInitialLoading: false, + data: mockAssignableSubsidyAccessPolicy, + }); + useBudgetDetailActivityOverview.mockReturnValue({ + isLoading: false, + data: mockEmptyStateBudgetDetailActivityOverview, + }); + useBudgetRedemptions.mockReturnValue({ + isLoading: false, + budgetRedemptions: mockEmptyBudgetRedemptions, + fetchBudgetRedemptions: jest.fn(), + }); + useEnterpriseGroupLearners.mockReturnValue({ + data: { + count: 1, + currentPage: 1, + next: null, + numPages: 1, + results: { + enterpriseGroupMembershipUuid: 'cde2e374-032f-4c08-8c0d-bf3205fa7c7e', + learnerId: 4382, + memberDetails: { userEmail: 'dukesilver@test.com', userName: 'duke silver' }, + }, }, - }, - }); - useEnterpriseGroupMembersTableData.mockReturnValue({ - isLoading: false, - enterpriseGroupMembersTableData: { - itemCount: 5, - pageCount: 1, - results: [{ - memberDetails: { userEmail: 'dukesilver@test.com', userName: 'duke silver' }, - status: 'pending', - recentAction: 'Pending: April 02, 2024', - enrollmentCount: 0, - }, { - memberDetails: { userEmail: 'bobbynewport@test.com', userName: 'bobby newport' }, - status: 'removed', - recentAction: 'Removed: April 02, 2024', - enrollmentCount: 0, - }, { - memberDetails: { userEmail: 'annperkins@test.com', userName: 'ann perkins' }, - status: 'accepted', - recentAction: 'Accepted: April 02, 2024', - enrollmentCount: 0, - }, { - memberDetails: { userEmail: 'andydwyer@test.com', userName: 'andy dwyer' }, - status: 'internal_api_error', - recentAction: 'Errored: April 01, 2024', - enrollmentCount: 0, - }, { - memberDetails: { userEmail: 'donnameagle@test.com', userName: 'donna meagle' }, - status: 'email_error', - recentAction: 'Errored: April 01, 2024', - enrollmentCount: 0, - }], - }, - fetchEnterpriseGroupMembersTableData: jest.fn(), - }); - renderWithRouter(); - await waitFor(() => expect(screen.queryByText('dukesilver@test.com')).toBeInTheDocument()); - userEvent.click(screen.getByText('Waiting for member')); - await waitFor(() => expect(screen.queryByText('Waiting for dukesilver@test.com')).toBeInTheDocument()); - screen.getByText('This member must accept their invitation to browse this budget\'s catalog and enroll using their ' - + 'member permissions by logging in or creating an account within 90 days.'); - // click again to close it out - userEvent.click(screen.getByText('Waiting for member')); - - userEvent.click(screen.getByText('Accepted')); - await waitFor(() => expect(screen.queryByText('Invitation accepted')).toBeInTheDocument()); - screen.getByText('This member has successfully accepted the member invitation and can ' - + 'now browse this budget\'s catalog and enroll using their member permissions.'); - userEvent.click(screen.getByText('Accepted')); - - userEvent.click(screen.getByText('Removed')); - await waitFor(() => expect(screen.queryByText('Member removed')).toBeInTheDocument()); - screen.getByText('This member has been successfully removed and can not browse this budget\'s ' - + 'catalog and enroll using their member permissions.'); - - userEvent.click(screen.getByText('Failed: System')); - await waitFor(() => expect(screen.queryByText('Something went wrong behind the scenes.')).toBeInTheDocument()); - - userEvent.click(screen.getByText('Failed: Bad email')); - await waitFor(() => expect(screen.queryByText('This member invitation failed because a notification to donnameagle@test.com ' - + 'could not be sent.')).toBeInTheDocument()); - }); + }); + useEnterpriseGroupMembersTableData.mockReturnValue({ + isLoading: false, + enterpriseGroupMembersTableData: { + itemCount: 5, + pageCount: 1, + results: [{ + memberDetails: { userEmail: 'dukesilver@test.com', userName: 'duke silver' }, + status: 'pending', + recentAction: 'Pending: April 02, 2024', + enrollmentCount: 0, + }, { + memberDetails: { userEmail: 'bobbynewport@test.com', userName: 'bobby newport' }, + status: 'removed', + recentAction: 'Removed: April 02, 2024', + enrollmentCount: 0, + }, { + memberDetails: { userEmail: 'annperkins@test.com', userName: 'ann perkins' }, + status: 'accepted', + recentAction: 'Accepted: April 02, 2024', + enrollmentCount: 0, + }, { + memberDetails: { userEmail: 'andydwyer@test.com', userName: 'andy dwyer' }, + status: 'internal_api_error', + recentAction: 'Errored: April 01, 2024', + enrollmentCount: 0, + }, { + memberDetails: { userEmail: 'donnameagle@test.com', userName: 'donna meagle' }, + status: 'email_error', + recentAction: 'Errored: April 01, 2024', + enrollmentCount: 0, + }], + }, + fetchEnterpriseGroupMembersTableData: jest.fn(), + }); + renderWithRouter(); + await waitFor(() => expect(screen.queryByText('dukesilver@test.com')).toBeInTheDocument()); + userEvent.click(screen.getByText('Waiting for member')); + await waitFor(() => expect(screen.queryByText('Waiting for dukesilver@test.com')).toBeInTheDocument()); + screen.getByText('This member must accept their invitation to browse this budget\'s catalog and enroll using their ' + + 'member permissions by logging in or creating an account within 90 days.'); + // click again to close it out + userEvent.click(screen.getByText('Waiting for member')); + + userEvent.click(screen.getByText('Accepted')); + await waitFor(() => expect(screen.queryByText('Invitation accepted')).toBeInTheDocument()); + screen.getByText('This member has successfully accepted the member invitation and can ' + + 'now browse this budget\'s catalog and enroll using their member permissions.'); + userEvent.click(screen.getByText('Accepted')); + + userEvent.click(screen.getByText('Removed')); + await waitFor(() => expect(screen.queryByText('Member removed')).toBeInTheDocument()); + screen.getByText('This member has been successfully removed and can not browse this budget\'s ' + + 'catalog and enroll using their member permissions.'); + + userEvent.click(screen.getByText('Failed: System')); + await waitFor(() => expect(screen.queryByText('Something went wrong behind the scenes.')).toBeInTheDocument()); + + userEvent.click(screen.getByText('Failed: Bad email')); + await waitFor(() => expect(screen.queryByText('This member invitation failed because a notification to donnameagle@test.com ' + + 'could not be sent.')).toBeInTheDocument()); + }, + // Increase the timeout from the default (5000 ms) to 9000 ms to give + // github actions a little more time to run this heavy/flaky test. + // FIXME: Longer term, we should break up this test so that there are not + // so many sequential click + waitFor. + 9000, + ); it('download learner flow for multiple selected pages of users', async () => { // Setup const initialState = {