diff --git a/src/components/learner-credit-management/assignment-modal/NewAssignmentModalButton.jsx b/src/components/learner-credit-management/assignment-modal/NewAssignmentModalButton.jsx index 63b6bfe8ee..9563f68b43 100644 --- a/src/components/learner-credit-management/assignment-modal/NewAssignmentModalButton.jsx +++ b/src/components/learner-credit-management/assignment-modal/NewAssignmentModalButton.jsx @@ -16,8 +16,13 @@ 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'; @@ -74,10 +79,19 @@ const NewAssignmentModalButton = ({ enterpriseId, course, children }) => { assignmentConfiguration, isLateRedemptionAllowed, }; + const { + dataByContentKey: catalogContainsRestrictedRunsData, + isLoading: isLoadingcatalogContainsRestrictedRuns, + } = useCatalogContainsContentItemsMultipleQueries( + catalogUuid, + // Pass only restricted runs. + course.courseRuns.filter(run => run.restrictionType === 'custom-e2e-enterprise'), // TODO: replace with constant + ); const assignableCourseRuns = getAssignableCourseRuns({ courseRuns: course.courseRuns, subsidyExpirationDatetime: subsidyAccessPolicy.subsidyExpirationDatetime, isLateRedemptionAllowed, + catalogContainsRestrictedRunsData, }); const { mutate } = useAllocateContentAssignments(); const pathToActivityTab = generatePath(LEARNER_CREDIT_ROUTE, { @@ -219,7 +233,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 a9545d8e82..c01c255914 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,11 @@ const BaseCourseCard = ({ formattedPrice, isExecEdCourseType, footerText, + isLoadingRestrictedRuns, } = courseCardMetadata; + const runPrice = formatPrice(courseRun.contentPrice); + const coursePrice = isLoadingRestrictedRuns ? : formattedPrice; + const cardPrice = courseRun ? runPrice : coursePrice; return ( -
{courseRun ? formatPrice(courseRun.contentPrice) : formattedPrice}
+
{cardPrice}
run.restrictionType === 'custom-e2e-enterprise'), // TODO: replace with constant + ); + const assignableCourseRuns = getAssignableCourseRuns({ courseRuns, subsidyExpirationDatetime: subsidyAccessPolicy.subsidyExpirationDatetime, isLateRedemptionAllowed: subsidyAccessPolicy.isLateRedemptionAllowed, + catalogContainsRestrictedRunsData, }); // Extracts the content price from assignable course runs @@ -98,6 +109,7 @@ const useCourseCardMetadata = ({ linkToCourse, isExecEdCourseType, footerText, + isLoading: isLoadingcatalogContainsRestrictedRuns, }; }; diff --git a/src/components/learner-credit-management/data/constants.js b/src/components/learner-credit-management/data/constants.js index abc7e43c4a..24126510fb 100644 --- a/src/components/learner-credit-management/data/constants.js +++ b/src/components/learner-credit-management/data/constants.js @@ -125,6 +125,12 @@ 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 diff --git a/src/components/learner-credit-management/data/hooks/index.js b/src/components/learner-credit-management/data/hooks/index.js index 6f1842953f..93413391be 100644 --- a/src/components/learner-credit-management/data/hooks/index.js +++ b/src/components/learner-credit-management/data/hooks/index.js @@ -23,3 +23,4 @@ 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 useCatalogContainsContentItemsMultipleQueries } from './useCatalogContainsContentItemsMultipleQueries'; 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..eb52d859d8 --- /dev/null +++ b/src/components/learner-credit-management/data/hooks/useCatalogContainsContentItemsMultipleQueries.js @@ -0,0 +1,52 @@ +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, + // 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])), + // This whole hook is considered to be still loading if either: + // 1. The input catalogUuid is falsey, implying the upstream + // waterfall query to fetch the policy has not yet returned, or + // 2. The input contentKeys is undefined/null, implying the upstream + // waterfall query to algolia has not yet returned, or + // 3. At least one query is still loading. + isLoading: ( + !catalogUuid + || contentKeys === undefined + || contentKeys === null + || multipleResults.some(result => result.isLoading) + ), + }; +}; + +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..d11fc96060 100644 --- a/src/components/learner-credit-management/data/utils.js +++ b/src/components/learner-credit-management/data/utils.js @@ -688,8 +688,20 @@ export const startAndEnrollBySortLogic = (prev, next) => { * @param isLateRedemptionAllowed * @returns {*} */ -export const getAssignableCourseRuns = ({ courseRuns, subsidyExpirationDatetime, isLateRedemptionAllowed }) => { - const clonedCourseRuns = courseRuns.map(courseRun => ({ +export const getAssignableCourseRuns = ({ + courseRuns, subsidyExpirationDatetime, + isLateRedemptionAllowed, + catalogContainsRestrictedRunsData, +}) => { + // First, whittle down the runs to just the ones that are either completely + // unrestricted, or restricted but allowed for the current subsidy. + const unrestrictedCourseRuns = courseRuns.filter(courseRun => { + if (!courseRun.restrictionType) { + return true; + } + return !!catalogContainsRestrictedRunsData[courseRun.key]?.containsContentItems; + }); + const clonedCourseRuns = unrestrictedCourseRuns.map(courseRun => ({ ...courseRun, enrollBy: courseRun.hasEnrollBy ? dayjs.unix(courseRun.enrollBy).toISOString() : null, enrollStart: courseRun.hasEnrollStart ? dayjs.unix(courseRun.enrollStart).toISOString() : null, 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;