diff --git a/src/components/learner-credit-management/assignment-modal/NewAssignmentModalButton.jsx b/src/components/learner-credit-management/assignment-modal/NewAssignmentModalButton.jsx index 63b6bfe8e..500455e00 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,31 @@ 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 +99,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..872fb904e 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..5f55d72f2 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,12 @@ 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', () => ({ + ensureConfig: jest.fn(), + getConfig: jest.fn(), +})); jest.mock('@edx/frontend-enterprise-utils', () => ({ ...jest.requireActual('@edx/frontend-enterprise-utils'), @@ -51,6 +59,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 +295,11 @@ describe('Course card works as expected', () => { useEnterpriseFlexGroups.mockReturnValue({ data: mockEnterpriseFlexGroup, }); + useCatalogContainsContentItemsMultipleQueries.mockReturnValue({ + data: {}, + dataByContentKey: {}, + isLoading: false, + }); }); afterEach(() => { @@ -861,4 +875,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').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 21e5e65f8..9a406d056 100644 --- a/src/components/learner-credit-management/data/hooks/index.js +++ b/src/components/learner-credit-management/data/hooks/index.js @@ -24,3 +24,4 @@ export { default as useEnterpriseRemovedGroupMembers } from './useEnterpriseRemo export { default as useEnterpriseFlexGroups } from './useEnterpriseFlexGroups'; export { default as useGroupDropdownToggle } from './useGroupDropdownToggle'; 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, }); }, },