Skip to content

Commit

Permalink
feat: enable allocation of restricted runs
Browse files Browse the repository at this point in the history
ENT-9411
  • Loading branch information
pwnage101 committed Nov 26, 2024
1 parent 3bb7650 commit 23415e4
Show file tree
Hide file tree
Showing 9 changed files with 174 additions and 27 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -219,7 +233,12 @@ const NewAssignmentModalButton = ({ enterpriseId, course, children }) => {
};
return (
<>
<NewAssignmentModalDropdown id={course.key} onClick={handleOpenAssignmentModal} courseRuns={assignableCourseRuns}>
<NewAssignmentModalDropdown
id={course.key}
onClick={handleOpenAssignmentModal}
courseRuns={assignableCourseRuns}
isLoading={isLoadingcatalogContainsRestrictedRuns}
>
{children}
</NewAssignmentModalDropdown>
<FullscreenModal
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { defineMessages, useIntl } from '@edx/frontend-platform/i18n';
import { Dropdown, Stack } from '@openedx/paragon';
import { Dropdown, Skeleton, Stack } from '@openedx/paragon';
import dayjs from 'dayjs';
import PropTypes from 'prop-types';
import { useState } from 'react';
Expand Down Expand Up @@ -34,6 +34,7 @@ const NewAssignmentModalDropdown = ({
onClick: openAssignmentModal,
courseRuns,
children,
isLoading,
}) => {
const intl = useIntl();
const [clickedDropdownItem, setClickedDropdownItem] = useState(null);
Expand All @@ -59,26 +60,31 @@ const NewAssignmentModalDropdown = ({
<Dropdown.Header className="text-uppercase">
{courseRuns.length > 0 ? intl.formatMessage(messages.byDate) : intl.formatMessage(messages.noAvailableDates) }
</Dropdown.Header>
{courseRuns.length > 0 && courseRuns.map(courseRun => (
<Dropdown.Item
key={courseRun.key}
data-courserunkey={courseRun.key}
onMouseDown={() => setClickedDropdownItem(courseRun)}
onMouseUp={() => setClickedDropdownItem(null)}
>
<Stack>
{intl.formatMessage(messages.startDate, {
startLabel: startLabel(courseRun),
startDate: dayjs(courseRun.start).format(SHORT_MONTH_DATE_FORMAT),
})}
<span className={classNames('small', { 'text-muted': getDropdownItemClassName(courseRun) })}>
{intl.formatMessage(messages.enrollBy, {
enrollByDate: dayjs(courseRun.enrollBy).format(SHORT_MONTH_DATE_FORMAT),
{courseRuns.length > 0 && courseRuns.map(courseRun => {
if (isLoading) {
return (<span className="dropdown-item"><Skeleton /></span>);
}
return (
<Dropdown.Item
key={courseRun.key}
data-courserunkey={courseRun.key}
onMouseDown={() => setClickedDropdownItem(courseRun)}
onMouseUp={() => setClickedDropdownItem(null)}
>
<Stack>
{intl.formatMessage(messages.startDate, {
startLabel: startLabel(courseRun),
startDate: dayjs(courseRun.start).format(SHORT_MONTH_DATE_FORMAT),
})}
</span>
</Stack>
</Dropdown.Item>
))}
<span className={classNames('small', { 'text-muted': getDropdownItemClassName(courseRun) })}>
{intl.formatMessage(messages.enrollBy, {
enrollByDate: dayjs(courseRun.enrollBy).format(SHORT_MONTH_DATE_FORMAT),
})}
</span>
</Stack>
</Dropdown.Item>
);
})}
</Dropdown.Menu>
</Dropdown>
);
Expand All @@ -93,6 +99,7 @@ NewAssignmentModalDropdown.propTypes = {
start: PropTypes.string,
})).isRequired,
children: PropTypes.node.isRequired,
isLoading: PropTypes.bool.isRequired,
};

export default NewAssignmentModalDropdown;
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -35,7 +35,11 @@ const BaseCourseCard = ({
formattedPrice,
isExecEdCourseType,
footerText,
isLoadingRestrictedRuns,
} = courseCardMetadata;
const runPrice = formatPrice(courseRun.contentPrice);
const coursePrice = isLoadingRestrictedRuns ? <Skeleton /> : formattedPrice;
const cardPrice = courseRun ? runPrice : coursePrice;
return (
<Card orientation={isSmall ? 'vertical' : 'horizontal'} className={cardClassName}>
<Card.ImageCap
Expand All @@ -50,7 +54,7 @@ const BaseCourseCard = ({
subtitle={subtitle}
actions={(
<Stack gap={1} className="text-right">
<div className="h4 mt-2.5 mb-0">{courseRun ? formatPrice(courseRun.contentPrice) : formattedPrice}</div>
<div className="h4 mt-2.5 mb-0">{cardPrice}</div>
<span className="micro">
<FormattedMessage
id="lcm.budget.detail.page.catalog.tab.course.card.price.per.learner"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
getAssignableCourseRuns,
getEnrollmentDeadline,
useBudgetId,
useCatalogContainsContentItemsMultipleQueries,
useSubsidyAccessPolicy,
} from '../../data';
import { pluralText } from '../../../../utils';
Expand Down Expand Up @@ -54,10 +55,20 @@ const useCourseCardMetadata = ({
title,
courseRuns,
} = course;
const {
dataByContentKey: catalogContainsRestrictedRunsData,
isLoading: isLoadingcatalogContainsRestrictedRuns,
} = useCatalogContainsContentItemsMultipleQueries(
subsidyAccessPolicy.catalogUuid,
// Pass only restricted runs.
courseRuns?.filter(run => 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
Expand Down Expand Up @@ -98,6 +109,7 @@ const useCourseCardMetadata = ({
linkToCourse,
isExecEdCourseType,
footerText,
isLoading: isLoadingcatalogContainsRestrictedRuns,
};
};

Expand Down
6 changes: 6 additions & 0 deletions src/components/learner-credit-management/data/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
@@ -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;
16 changes: 14 additions & 2 deletions src/components/learner-credit-management/data/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
34 changes: 34 additions & 0 deletions src/data/services/EnterpriseCatalogApiServiceV2.js
Original file line number Diff line number Diff line change
@@ -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;

0 comments on commit 23415e4

Please sign in to comment.