From 5918556f30c1f6a0e8816a29b2d1bbc4f8e7a750 Mon Sep 17 00:00:00 2001 From: Troy Sankey Date: Mon, 25 Nov 2024 21:59:01 -0800 Subject: [PATCH 1/2] 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 63b6bfe8ee..994b8ca688 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 a9545d8e82..6286e7dd33 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 d2fb046dc7..389ff0af66 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 abc7e43c4a..195b3e7b99 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 c8df3b7885..cdd1006ddf 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 0000000000..45d730bb62 --- /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 0000000000..3ce35d6e51 --- /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 52a750c056..2ce61d2115 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 0000000000..ae7199355d --- /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 814b110d04..7881842184 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 2/2] 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 d7ce81e193..750d43f461 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 = {