From 5918556f30c1f6a0e8816a29b2d1bbc4f8e7a750 Mon Sep 17 00:00:00 2001 From: Troy Sankey Date: Mon, 25 Nov 2024 21:59:01 -0800 Subject: [PATCH 1/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 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/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 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 = { From 371f043183c83e72a5a127fb9dcafa785fe03e35 Mon Sep 17 00:00:00 2001 From: Kira Miller <31229189+kiram15@users.noreply.github.com> Date: Thu, 5 Dec 2024 11:30:04 -0700 Subject: [PATCH 3/3] Adding people management data table (#1364) * fix: formatting without data * fix: adding in tests * fix: teeny fix --- .../EnterpriseApp/EnterpriseAppRoutes.jsx | 2 +- .../DeleteGroupModal.jsx | 6 +- .../EditGroupNameModal.jsx | 6 +- .../{ => GroupDetailPage}/GroupDetailPage.jsx | 8 +-- .../PeopleManagement/OrgMemberCard.jsx | 53 +++++++++++++++ .../PeopleManagementTable.jsx | 68 +++++++++++++++++++ src/components/PeopleManagement/constants.js | 8 +++ .../PeopleManagement/data/hooks/index.js | 3 + .../useEnterpriseGroupLearnersTableData.js | 0 .../data/hooks/useEnterpriseGroupUuid.js | 2 +- .../hooks/useEnterpriseMembersTableData.js | 62 +++++++++++++++++ src/components/PeopleManagement/index.jsx | 28 +++++++- .../tests/GroupDetailPage.test.jsx | 8 +-- ...eEnterpriseGroupLearnersTableData.test.jsx | 4 +- .../useEnterpriseMembersTableData.test.jsx | 42 ++++++++++++ .../data/hooks/index.js | 2 - src/data/services/LmsApiService.js | 11 +++ 17 files changed, 290 insertions(+), 23 deletions(-) rename src/components/PeopleManagement/{ => GroupDetailPage}/DeleteGroupModal.jsx (93%) rename src/components/PeopleManagement/{ => GroupDetailPage}/EditGroupNameModal.jsx (95%) rename src/components/PeopleManagement/{ => GroupDetailPage}/GroupDetailPage.jsx (96%) create mode 100644 src/components/PeopleManagement/OrgMemberCard.jsx create mode 100644 src/components/PeopleManagement/PeopleManagementTable.jsx create mode 100644 src/components/PeopleManagement/data/hooks/index.js rename src/components/{learner-credit-management => PeopleManagement}/data/hooks/useEnterpriseGroupLearnersTableData.js (100%) rename src/components/{learner-credit-management => PeopleManagement}/data/hooks/useEnterpriseGroupUuid.js (89%) create mode 100644 src/components/PeopleManagement/data/hooks/useEnterpriseMembersTableData.js rename src/components/{learner-credit-management/data/hooks => PeopleManagement}/tests/useEnterpriseGroupLearnersTableData.test.jsx (92%) create mode 100644 src/components/PeopleManagement/tests/useEnterpriseMembersTableData.test.jsx diff --git a/src/components/EnterpriseApp/EnterpriseAppRoutes.jsx b/src/components/EnterpriseApp/EnterpriseAppRoutes.jsx index 731b34ff9b..02ac0d708a 100644 --- a/src/components/EnterpriseApp/EnterpriseAppRoutes.jsx +++ b/src/components/EnterpriseApp/EnterpriseAppRoutes.jsx @@ -19,7 +19,7 @@ import { EnterpriseSubsidiesContext } from '../EnterpriseSubsidiesContext'; import ContentHighlights from '../ContentHighlights'; import LearnerCreditManagementRoutes from '../learner-credit-management'; import PeopleManagementPage from '../PeopleManagement'; -import GroupDetailPage from '../PeopleManagement/GroupDetailPage'; +import GroupDetailPage from '../PeopleManagement/GroupDetailPage/GroupDetailPage'; const EnterpriseAppRoutes = ({ email, diff --git a/src/components/PeopleManagement/DeleteGroupModal.jsx b/src/components/PeopleManagement/GroupDetailPage/DeleteGroupModal.jsx similarity index 93% rename from src/components/PeopleManagement/DeleteGroupModal.jsx rename to src/components/PeopleManagement/GroupDetailPage/DeleteGroupModal.jsx index c0f67802c1..ccf98ee650 100644 --- a/src/components/PeopleManagement/DeleteGroupModal.jsx +++ b/src/components/PeopleManagement/GroupDetailPage/DeleteGroupModal.jsx @@ -8,10 +8,10 @@ import { } from '@openedx/paragon'; import { RemoveCircleOutline } from '@openedx/paragon/icons'; -import GeneralErrorModal from './GeneralErrorModal'; -import { ROUTE_NAMES } from '../EnterpriseApp/data/constants'; +import GeneralErrorModal from '../GeneralErrorModal'; +import { ROUTE_NAMES } from '../../EnterpriseApp/data/constants'; -import LmsApiService from '../../data/services/LmsApiService'; +import LmsApiService from '../../../data/services/LmsApiService'; const DeleteGroupModal = ({ group, isOpen, close, diff --git a/src/components/PeopleManagement/EditGroupNameModal.jsx b/src/components/PeopleManagement/GroupDetailPage/EditGroupNameModal.jsx similarity index 95% rename from src/components/PeopleManagement/EditGroupNameModal.jsx rename to src/components/PeopleManagement/GroupDetailPage/EditGroupNameModal.jsx index ca43f78bd0..b548cb22d2 100644 --- a/src/components/PeopleManagement/EditGroupNameModal.jsx +++ b/src/components/PeopleManagement/GroupDetailPage/EditGroupNameModal.jsx @@ -6,9 +6,9 @@ import { ActionRow, Form, ModalDialog, Spinner, StatefulButton, useToggle, } from '@openedx/paragon'; -import { MAX_LENGTH_GROUP_NAME } from './constants'; -import LmsApiService from '../../data/services/LmsApiService'; -import GeneralErrorModal from './GeneralErrorModal'; +import { MAX_LENGTH_GROUP_NAME } from '../constants'; +import LmsApiService from '../../../data/services/LmsApiService'; +import GeneralErrorModal from '../GeneralErrorModal'; const EditGroupNameModal = ({ group, isOpen, close, handleNameUpdate, diff --git a/src/components/PeopleManagement/GroupDetailPage.jsx b/src/components/PeopleManagement/GroupDetailPage/GroupDetailPage.jsx similarity index 96% rename from src/components/PeopleManagement/GroupDetailPage.jsx rename to src/components/PeopleManagement/GroupDetailPage/GroupDetailPage.jsx index d47f543038..84a0f2d782 100644 --- a/src/components/PeopleManagement/GroupDetailPage.jsx +++ b/src/components/PeopleManagement/GroupDetailPage/GroupDetailPage.jsx @@ -6,12 +6,12 @@ import { } from '@openedx/paragon'; import { Delete, Edit } from '@openedx/paragon/icons'; -import { useEnterpriseGroupLearnersTableData, useEnterpriseGroupUuid } from '../learner-credit-management/data'; -import { ROUTE_NAMES } from '../EnterpriseApp/data/constants'; +import { useEnterpriseGroupLearnersTableData, useEnterpriseGroupUuid } from '../data/hooks'; +import { ROUTE_NAMES } from '../../EnterpriseApp/data/constants'; import DeleteGroupModal from './DeleteGroupModal'; import EditGroupNameModal from './EditGroupNameModal'; -import formatDates from './utils'; -import GroupMembersTable from './GroupMembersTable'; +import formatDates from '../utils'; +import GroupMembersTable from '../GroupMembersTable'; const GroupDetailPage = () => { const intl = useIntl(); diff --git a/src/components/PeopleManagement/OrgMemberCard.jsx b/src/components/PeopleManagement/OrgMemberCard.jsx new file mode 100644 index 0000000000..6ae9e48042 --- /dev/null +++ b/src/components/PeopleManagement/OrgMemberCard.jsx @@ -0,0 +1,53 @@ +import PropTypes from 'prop-types'; + +import { + Avatar, Card, Col, Row, +} from '@openedx/paragon'; + +const OrgMemberCard = ({ original }) => { + const { enterpriseCustomerUser, enrollments } = original; + const { name, joinedOrg, email } = enterpriseCustomerUser; + + return ( + + + + + + + + + +

{name}

+
+ +

{email}

+
+ + +
Joined org
+ {joinedOrg} + + +
Enrollments
+ {enrollments} + +
+
+
+
+ ); +}; + +OrgMemberCard.propTypes = { + original: PropTypes.shape({ + enterpriseCustomerUser: PropTypes.shape({ + email: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + joinedOrg: PropTypes.string.isRequired, + }), + enrollments: PropTypes.number.isRequired, + }), +}; + +export default OrgMemberCard; diff --git a/src/components/PeopleManagement/PeopleManagementTable.jsx b/src/components/PeopleManagement/PeopleManagementTable.jsx new file mode 100644 index 0000000000..62d0d5745b --- /dev/null +++ b/src/components/PeopleManagement/PeopleManagementTable.jsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { CardView, DataTable } from '@openedx/paragon'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; + +import TableTextFilter from '../learner-credit-management/TableTextFilter'; +import CustomDataTableEmptyState from '../learner-credit-management/CustomDataTableEmptyState'; +import OrgMemberCard from './OrgMemberCard'; +import useEnterpriseMembersTableData from './data/hooks/useEnterpriseMembersTableData'; + +const FilterStatus = (rest) => ; + +const PeopleManagementTable = ({ enterpriseId }) => { + const { + isLoading: isTableLoading, + enterpriseMembersTableData, + fetchEnterpriseMembersTableData, + } = useEnterpriseMembersTableData({ enterpriseId }); + + const tableColumns = [{ Header: 'Name', accessor: 'name' }]; + + return ( + + + + + + ); +}; + +PeopleManagementTable.propTypes = { + enterpriseId: PropTypes.string.isRequired, +}; + +const mapStateToProps = state => ({ + enterpriseId: state.portalConfiguration.enterpriseId, +}); + +export default connect(mapStateToProps)(PeopleManagementTable); diff --git a/src/components/PeopleManagement/constants.js b/src/components/PeopleManagement/constants.js index 2c8a9de50f..ef2d298c3f 100644 --- a/src/components/PeopleManagement/constants.js +++ b/src/components/PeopleManagement/constants.js @@ -2,7 +2,15 @@ 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 + +// Query Key factory for the people management module, intended to be used with `@tanstack/react-query`. +// Inspired by https://tkdodo.eu/blog/effective-react-query-keys#use-query-key-factories. +export const peopleManagementQueryKeys = { + all: ['people-management'], + members: (enterpriseUuid) => [...peopleManagementQueryKeys.all, 'members', enterpriseUuid], +}; diff --git a/src/components/PeopleManagement/data/hooks/index.js b/src/components/PeopleManagement/data/hooks/index.js new file mode 100644 index 0000000000..04bcc3b90e --- /dev/null +++ b/src/components/PeopleManagement/data/hooks/index.js @@ -0,0 +1,3 @@ +export { default as useEnterpriseGroupUuid } from './useEnterpriseGroupUuid'; +export { default as useEnterpriseGroupLearnersTableData } from './useEnterpriseGroupLearnersTableData'; +export { default as useEnterpriseMembersTableData } from './useEnterpriseMembersTableData'; diff --git a/src/components/learner-credit-management/data/hooks/useEnterpriseGroupLearnersTableData.js b/src/components/PeopleManagement/data/hooks/useEnterpriseGroupLearnersTableData.js similarity index 100% rename from src/components/learner-credit-management/data/hooks/useEnterpriseGroupLearnersTableData.js rename to src/components/PeopleManagement/data/hooks/useEnterpriseGroupLearnersTableData.js diff --git a/src/components/learner-credit-management/data/hooks/useEnterpriseGroupUuid.js b/src/components/PeopleManagement/data/hooks/useEnterpriseGroupUuid.js similarity index 89% rename from src/components/learner-credit-management/data/hooks/useEnterpriseGroupUuid.js rename to src/components/PeopleManagement/data/hooks/useEnterpriseGroupUuid.js index c2a3dc91ec..a8e97e495f 100644 --- a/src/components/learner-credit-management/data/hooks/useEnterpriseGroupUuid.js +++ b/src/components/PeopleManagement/data/hooks/useEnterpriseGroupUuid.js @@ -1,7 +1,7 @@ import { useQuery } from '@tanstack/react-query'; import { camelCaseObject } from '@edx/frontend-platform/utils'; -import { learnerCreditManagementQueryKeys } from '../constants'; +import { learnerCreditManagementQueryKeys } from '../../../learner-credit-management/data/constants'; import LmsApiService from '../../../../data/services/LmsApiService'; /** diff --git a/src/components/PeopleManagement/data/hooks/useEnterpriseMembersTableData.js b/src/components/PeopleManagement/data/hooks/useEnterpriseMembersTableData.js new file mode 100644 index 0000000000..d2bccdc74f --- /dev/null +++ b/src/components/PeopleManagement/data/hooks/useEnterpriseMembersTableData.js @@ -0,0 +1,62 @@ +import { + useCallback, useMemo, useState, +} from 'react'; +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 useEnterpriseMembersTableData = ({ enterpriseId }) => { + const [isLoading, setIsLoading] = useState(true); + const [enterpriseMembersTableData, setEnterpriseMembersTableData] = useState({ + itemCount: 0, + pageCount: 0, + results: [], + }); + const fetchEnterpriseMembersData = useCallback((args) => { + const fetch = async () => { + try { + setIsLoading(true); + const options = {}; + args.filters.forEach((filter) => { + const { id, value } = filter; + if (id === 'name') { + options.user_query = value; + } + }); + + options.page = args.pageIndex + 1; + const response = await LmsApiService.fetchEnterpriseCustomerMembers(enterpriseId, options); + const data = camelCaseObject(response.data); + setEnterpriseMembersTableData({ + itemCount: data.count, + pageCount: data.numPages ?? Math.floor(data.count / options.pageSize), + results: data.results, + }); + } catch (error) { + logError(error); + } finally { + setIsLoading(false); + } + }; + if (args.filters.length && args.filters[0].value.length > 2) { + fetch(); + } else if (!args.filters.length) { + fetch(); + } + }, [enterpriseId]); + + const debouncedFetchEnterpriseMembersData = useMemo( + () => debounce(fetchEnterpriseMembersData, 300), + [fetchEnterpriseMembersData], + ); + + return { + isLoading, + enterpriseMembersTableData, + fetchEnterpriseMembersTableData: debouncedFetchEnterpriseMembersData, + }; +}; + +export default useEnterpriseMembersTableData; diff --git a/src/components/PeopleManagement/index.jsx b/src/components/PeopleManagement/index.jsx index 27e8c88589..7cf6614184 100644 --- a/src/components/PeopleManagement/index.jsx +++ b/src/components/PeopleManagement/index.jsx @@ -13,6 +13,7 @@ import CreateGroupModal from './CreateGroupModal'; import { useAllEnterpriseGroups } from '../learner-credit-management/data'; import ZeroState from './ZeroState'; import GroupCardGrid from './GroupCardGrid'; +import PeopleManagementTable from './PeopleManagementTable'; const PeopleManagementPage = ({ enterpriseId }) => { const intl = useIntl(); @@ -78,16 +79,37 @@ const PeopleManagementPage = ({ enterpriseId }) => { description="CTA button text to open new group modal." /> - + {groups && groups.length > 0 ? ( - ) : } + + ) : ( + + )} +

+ +

+ + ); }; -const mapStateToProps = state => ({ +const mapStateToProps = (state) => ({ enterpriseId: state.portalConfiguration.enterpriseId, }); diff --git a/src/components/PeopleManagement/tests/GroupDetailPage.test.jsx b/src/components/PeopleManagement/tests/GroupDetailPage.test.jsx index e00d2fbd98..d93a063440 100644 --- a/src/components/PeopleManagement/tests/GroupDetailPage.test.jsx +++ b/src/components/PeopleManagement/tests/GroupDetailPage.test.jsx @@ -8,8 +8,8 @@ import { Provider } from 'react-redux'; import userEvent from '@testing-library/user-event'; import { IntlProvider } from '@edx/frontend-platform/i18n'; -import { useEnterpriseGroupUuid, useEnterpriseGroupLearnersTableData } from '../../learner-credit-management/data'; -import GroupDetailPage from '../GroupDetailPage'; +import { useEnterpriseGroupUuid, useEnterpriseGroupLearnersTableData } from '../data/hooks'; +import GroupDetailPage from '../GroupDetailPage/GroupDetailPage'; import LmsApiService from '../../../data/services/LmsApiService'; const TEST_ENTERPRISE_SLUG = 'test-enterprise'; @@ -23,8 +23,8 @@ const TEST_GROUP = { const mockStore = configureMockStore([thunk]); const getMockStore = store => mockStore(store); -jest.mock('../../learner-credit-management/data', () => ({ - ...jest.requireActual('../../learner-credit-management/data'), +jest.mock('../data/hooks', () => ({ + ...jest.requireActual('../data/hooks'), useEnterpriseGroupUuid: jest.fn(), useEnterpriseGroupLearnersTableData: jest.fn(), })); diff --git a/src/components/learner-credit-management/data/hooks/tests/useEnterpriseGroupLearnersTableData.test.jsx b/src/components/PeopleManagement/tests/useEnterpriseGroupLearnersTableData.test.jsx similarity index 92% rename from src/components/learner-credit-management/data/hooks/tests/useEnterpriseGroupLearnersTableData.test.jsx rename to src/components/PeopleManagement/tests/useEnterpriseGroupLearnersTableData.test.jsx index ec49d431e3..a8b923090c 100644 --- a/src/components/learner-credit-management/data/hooks/tests/useEnterpriseGroupLearnersTableData.test.jsx +++ b/src/components/PeopleManagement/tests/useEnterpriseGroupLearnersTableData.test.jsx @@ -1,7 +1,7 @@ import { renderHook } from '@testing-library/react-hooks'; import { camelCaseObject } from '@edx/frontend-platform/utils'; -import LmsApiService from '../../../../../data/services/LmsApiService'; -import { useEnterpriseGroupLearnersTableData } from '../..'; +import LmsApiService from '../../../data/services/LmsApiService'; +import { useEnterpriseGroupLearnersTableData } from '../data/hooks'; describe('useEnterpriseGroupLearnersTableData', () => { it('should fetch and return enterprise learners', async () => { diff --git a/src/components/PeopleManagement/tests/useEnterpriseMembersTableData.test.jsx b/src/components/PeopleManagement/tests/useEnterpriseMembersTableData.test.jsx new file mode 100644 index 0000000000..d12aca1e67 --- /dev/null +++ b/src/components/PeopleManagement/tests/useEnterpriseMembersTableData.test.jsx @@ -0,0 +1,42 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { camelCaseObject } from '@edx/frontend-platform/utils'; +import LmsApiService from '../../../data/services/LmsApiService'; + +import useEnterpriseMembersTableData from '../data/hooks/useEnterpriseMembersTableData'; + +describe('useEnterpriseMembersTableData', () => { + it('should fetch and return members of an enterprise', async () => { + const mockEnterpriseUUID = 'uuid-bb'; + const mockData = { + count: 1, + current_page: 1, + next: null, + num_pages: 1, + previous: null, + results: [{ + enterprise_customer_user: { + email: 'jeez.louise@example.com', + joinedOrg: 'Sep 15, 2021', + name: 'Jeez Louise', + }, + enrollments: 11, + }], + }; + const mockEnterpriseMembers = jest.spyOn(LmsApiService, 'fetchEnterpriseCustomerMembers'); + mockEnterpriseMembers.mockResolvedValue({ data: mockData }); + + const { result, waitForNextUpdate } = renderHook( + () => useEnterpriseMembersTableData({ enterpriseId: mockEnterpriseUUID }), + ); + result.current.fetchEnterpriseMembersTableData({ + pageIndex: 0, + pageSize: 10, + filters: [], + sortBy: [], + }); + await waitForNextUpdate(); + expect(LmsApiService.fetchEnterpriseCustomerMembers).toHaveBeenCalledWith(mockEnterpriseUUID, { page: 1 }); + expect(result.current.isLoading).toEqual(false); + expect(result.current.enterpriseMembersTableData.results).toEqual(camelCaseObject(mockData.results)); + }); +}); diff --git a/src/components/learner-credit-management/data/hooks/index.js b/src/components/learner-credit-management/data/hooks/index.js index cdd1006ddf..da1e448ab8 100644 --- a/src/components/learner-credit-management/data/hooks/index.js +++ b/src/components/learner-credit-management/data/hooks/index.js @@ -17,12 +17,10 @@ export { default as useEnterpriseGroupLearners } from './useEnterpriseGroupLearn export { default as useEnterpriseGroupMembersTableData } from './useEnterpriseGroupMembersTableData'; export { default as useEnterpriseCustomer } from './useEnterpriseCustomer'; export { default as useEnterpriseGroup } from './useEnterpriseGroup'; -export { default as useEnterpriseGroupUuid } from './useEnterpriseGroupUuid'; export { default as useAllEnterpriseGroups } from './useAllEnterpriseGroups'; 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'; export { default as useCatalogContainsContentItemsMultipleQueries } from './useCatalogContainsContentItemsMultipleQueries'; diff --git a/src/data/services/LmsApiService.js b/src/data/services/LmsApiService.js index 40a388e1a6..3ac99b3c89 100644 --- a/src/data/services/LmsApiService.js +++ b/src/data/services/LmsApiService.js @@ -17,6 +17,8 @@ class LmsApiService { static enterpriseCustomerBrandingUrl = `${LmsApiService.baseUrl}/enterprise/api/v1/enterprise-customer-branding/update-branding/`; + static enterpriseCustomerMembersUrl = `${LmsApiService.baseUrl}/enterprise/api/v1/enterprise-customer-members/`; + static providerConfigUrl = `${LmsApiService.baseUrl}/auth/saml/v0/provider_config/`; static providerDataUrl = `${LmsApiService.baseUrl}/auth/saml/v0/provider_data/`; @@ -353,6 +355,15 @@ class LmsApiService { return LmsApiService.apiClient().patch(url, options); } + static fetchEnterpriseCustomerMembers(enterpriseUUID, options) { + let url = `${LmsApiService.enterpriseCustomerMembersUrl}${enterpriseUUID}/`; + if (options) { + const queryParams = new URLSearchParams(options); + url = `${LmsApiService.enterpriseCustomerMembersUrl}${enterpriseUUID}?${queryParams.toString()}`; + } + return LmsApiService.apiClient().get(url, options); + } + /** * Disables EnterpriseCustomerInviteKey * @param {string} enterpriseCustomerInviteKeyUUID uuid EnterpriseCustomerInviteKey to disable