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 = {