From dbe78bac5b5f6eb8b975108906fca3176a475e22 Mon Sep 17 00:00:00 2001 From: Kira Miller Date: Fri, 5 Apr 2024 04:07:19 +0000 Subject: [PATCH] feat: adding new budget headers --- .../BudgetDetailActivityTabContents.jsx | 12 ++-- .../BudgetDetailPageHeader.jsx | 36 ++++------- .../BudgetDetailPageOverviewAvailability.jsx | 59 ++++++++++------- .../BudgetDetailPageWrapper.jsx | 12 +++- .../BudgetStatusSubtitle.jsx | 59 +++++++++++++++++ .../data/constants.js | 1 + .../hooks/tests/useEnterpriseGroup.test.jsx | 64 +++++++++++++++++++ .../data/hooks/useEnterpriseGroup.js | 28 ++++++++ src/data/services/LmsApiService.js | 5 ++ 9 files changed, 221 insertions(+), 55 deletions(-) create mode 100644 src/components/learner-credit-management/BudgetStatusSubtitle.jsx create mode 100644 src/components/learner-credit-management/data/hooks/tests/useEnterpriseGroup.test.jsx create mode 100644 src/components/learner-credit-management/data/hooks/useEnterpriseGroup.js diff --git a/src/components/learner-credit-management/BudgetDetailActivityTabContents.jsx b/src/components/learner-credit-management/BudgetDetailActivityTabContents.jsx index e0e7cc7bc0..3ac443dd78 100644 --- a/src/components/learner-credit-management/BudgetDetailActivityTabContents.jsx +++ b/src/components/learner-credit-management/BudgetDetailActivityTabContents.jsx @@ -1,20 +1,22 @@ -import React from 'react'; +import React, { useContext } from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; -import { Stack, Skeleton, useToggle } from '@edx/paragon'; +import { Stack, Skeleton } from '@edx/paragon'; -import BudgetDetailRedemptions from './BudgetDetailRedemptions'; import BudgetDetailAssignments from './BudgetDetailAssignments'; +import BudgetDetailRedemptions from './BudgetDetailRedemptions'; +import { BudgetDetailPageContext } from './BudgetDetailPageWrapper'; import { useBudgetDetailActivityOverview, useBudgetId, useSubsidyAccessPolicy } from './data'; import NoAssignableBudgetActivity from './empty-state/NoAssignableBudgetActivity'; import NoBnEBudgetActivity from './empty-state/NoBnEBudgetActivity'; import InviteMembersModalWrapper from './invite-modal/InviteMembersModalWrapper'; const BudgetDetailActivityTabContents = ({ enterpriseUUID, enterpriseFeatures }) => { - const [inviteModalIsOpen, openInviteModal, closeInviteModal] = useToggle(false); const isTopDownAssignmentEnabled = enterpriseFeatures.topDownAssignmentRealTimeLcm; const isEnterpriseGroupsEnabled = enterpriseFeatures.enterpriseGroupsV1; - + const { + inviteModalIsOpen, openInviteModal, closeInviteModal, + } = useContext(BudgetDetailPageContext); const { enterpriseOfferId, subsidyAccessPolicyId } = useBudgetId(); const { data: subsidyAccessPolicy } = useSubsidyAccessPolicy(subsidyAccessPolicyId); const { diff --git a/src/components/learner-credit-management/BudgetDetailPageHeader.jsx b/src/components/learner-credit-management/BudgetDetailPageHeader.jsx index 7c359ecc17..2dcde6b755 100644 --- a/src/components/learner-credit-management/BudgetDetailPageHeader.jsx +++ b/src/components/learner-credit-management/BudgetDetailPageHeader.jsx @@ -1,34 +1,20 @@ import React from 'react'; -import { - Stack, Card, Badge, Skeleton, -} from '@edx/paragon'; - import { connect } from 'react-redux'; import PropTypes from 'prop-types'; +import { Card, Skeleton, Stack } from '@edx/paragon'; + import { useBudgetId, useSubsidyAccessPolicy, useBudgetDetailHeaderData, useEnterpriseOffer, - formatDate, useSubsidySummaryAnalyticsApi, } from './data'; - import BudgetDetailPageBreadcrumbs from './BudgetDetailPageBreadcrumbs'; import BudgetDetailPageOverviewAvailability from './BudgetDetailPageOverviewAvailability'; import BudgetDetailPageOverviewUtilization from './BudgetDetailPageOverviewUtilization'; import { BUDGET_TYPES } from '../EnterpriseApp/data/constants'; - -const BudgetStatusBadge = ({ - badgeVariant, status, term, date, -}) => ( - - {status} - {(term && date) && ( - {term} {formatDate(date)} - )} - -); +import BudgetStatusSubtitle from './BudgetStatusSubtitle'; const BudgetDetailPageHeader = ({ enterpriseUUID, enterpriseFeatures }) => { const { subsidyAccessPolicyId, enterpriseOfferId } = useBudgetId(); @@ -77,7 +63,14 @@ const BudgetDetailPageHeader = ({ enterpriseUUID, enterpriseFeatures }) => {

{budgetDisplayName}

- + { const { enterpriseSlug, enterpriseAppPage } = useParams(); - const supportUrl = configuration.ENTERPRISE_SUPPORT_URL; const { subsidyAccessPolicyId } = useBudgetId(); const { data: subsidyAccessPolicy } = useSubsidyAccessPolicy(subsidyAccessPolicyId); + const { data: appliesToAllContexts } = useEnterpriseGroup(subsidyAccessPolicy); + const { openInviteModal } = useContext(BudgetDetailPageContext); const trackEventMetadata = {}; if (subsidyAccessPolicy) { @@ -65,29 +67,38 @@ const BudgetActions = ({ budgetId, isAssignable, enterpriseId }) => { ); } - const isLargeScreenOrGreater = useMediaQuery({ query: `(min-width: ${breakpoints.small.minWidth}px)` }); - if (!isAssignable) { + if (appliesToAllContexts === true) { + return ( +
+
+

Manage edX for your organization

+

+ All people in your organization can choose what to learn + from the catalog and spend from the available balance to enroll. +

+ + + , +
+
+ ); + } return (
-

Get people learning using this budget

+

Drive learner-led enrollments by inviting members

- Funds from this budget are set to auto-allocate to registered learners based on - settings configured with your support team. + Members of this budget can choose what to learn from the catalog + and spend from the available balance to enroll.

@@ -96,9 +107,11 @@ const BudgetActions = ({ budgetId, isAssignable, enterpriseId }) => { return (
-
-

Get people learning using this budget

+
+

Lead the way to learning that matters

+

Assign content to people using the available budget to cover the cost of enrollment.

diff --git a/src/components/learner-credit-management/BudgetDetailPageWrapper.jsx b/src/components/learner-credit-management/BudgetDetailPageWrapper.jsx index 8d32729e4c..42efc2beca 100644 --- a/src/components/learner-credit-management/BudgetDetailPageWrapper.jsx +++ b/src/components/learner-credit-management/BudgetDetailPageWrapper.jsx @@ -1,7 +1,7 @@ import React, { useMemo } from 'react'; import PropTypes from 'prop-types'; import { Helmet } from 'react-helmet'; -import { Container, Toast } from '@edx/paragon'; +import { Container, Toast, useToggle } from '@edx/paragon'; import Hero from '../Hero'; import { @@ -68,12 +68,20 @@ const BudgetDetailPageWrapper = ({ closeToastForInvitation, } = successfulInvitationToast; + const [inviteModalIsOpen, openInviteModal, closeInviteModal] = useToggle(false); + const values = useMemo(() => ({ successfulAssignmentToast, successfulCancellationToast, successfulReminderToast, successfulInvitationToast, - }), [successfulAssignmentToast, successfulCancellationToast, successfulReminderToast, successfulInvitationToast]); + inviteModalIsOpen, + openInviteModal, + closeInviteModal, + }), [ + successfulAssignmentToast, successfulCancellationToast, + successfulReminderToast, successfulInvitationToast, + inviteModalIsOpen, openInviteModal, closeInviteModal]); return ( diff --git a/src/components/learner-credit-management/BudgetStatusSubtitle.jsx b/src/components/learner-credit-management/BudgetStatusSubtitle.jsx new file mode 100644 index 0000000000..5ad0cbca5a --- /dev/null +++ b/src/components/learner-credit-management/BudgetStatusSubtitle.jsx @@ -0,0 +1,59 @@ +import React from 'react'; + +import PropTypes from 'prop-types'; +import { + Badge, Icon, OverlayTrigger, Stack, Tooltip, +} from '@edx/paragon'; +import { GroupAdd, Groups } from '@edx/paragon/icons'; + +import { formatDate } from './data'; +import useEnterpriseGroup from './data/hooks/useEnterpriseGroup'; + +const BudgetStatusSubtitle = ({ + badgeVariant, status, isAssignable, term, date, policy, +}) => { + const { data } = useEnterpriseGroup(policy); + const universalGroup = data?.appliesToAllContexts; + const budgetType = isAssignable ? 'Assignable' : 'Browse & Enroll'; + const popoverText = universalGroup ? 'all people in your organization' : 'members added to this budget'; + return ( + + {(status !== 'Active') && ( + {status} + )} + + {(term && date) && ( + // budget expiration date + {term} {formatDate(date)} + )} + • {budgetType} + {(!isAssignable) && ( + • + + Available to {popoverText} + + )} + > + + + + )} + + + ); +}; + +BudgetStatusSubtitle.propTypes = { + badgeVariant: PropTypes.string.isRequired, + status: PropTypes.string.isRequired, + isAssignable: PropTypes.bool.isRequired, + term: PropTypes.string.isRequired, + date: PropTypes.string.isRequired, + policy: PropTypes.shape({}).isRequired, +}; + +export default BudgetStatusSubtitle; diff --git a/src/components/learner-credit-management/data/constants.js b/src/components/learner-credit-management/data/constants.js index 6d82c02338..4892d3a75e 100644 --- a/src/components/learner-credit-management/data/constants.js +++ b/src/components/learner-credit-management/data/constants.js @@ -74,4 +74,5 @@ export const learnerCreditManagementQueryKeys = { budgetEnterpriseOffer: (budgetId) => [...learnerCreditManagementQueryKeys.budget(budgetId), 'ecommerce'], budgetActivity: (budgetId) => [...learnerCreditManagementQueryKeys.budget(budgetId), 'activity'], budgetActivityOverview: (budgetId) => [...learnerCreditManagementQueryKeys.budgetActivity(budgetId), 'overview'], + group: (groupUuid) => [...learnerCreditManagementQueryKeys.all, 'group', groupUuid], }; diff --git a/src/components/learner-credit-management/data/hooks/tests/useEnterpriseGroup.test.jsx b/src/components/learner-credit-management/data/hooks/tests/useEnterpriseGroup.test.jsx new file mode 100644 index 0000000000..4ce490e0ae --- /dev/null +++ b/src/components/learner-credit-management/data/hooks/tests/useEnterpriseGroup.test.jsx @@ -0,0 +1,64 @@ +import { QueryClientProvider } from '@tanstack/react-query'; +import { renderHook } from '@testing-library/react-hooks'; + +import useEnterpriseGroup from '../useEnterpriseGroup'; +import LmsApiService from '../../../../../data/services/LmsApiService'; +import { queryClient } from '../../../../test/testUtils'; + +const wrapper = ({ children }) => ( + {children} +); + +const mockSubsidyAccessPolicy = { + uuid: 'test-subsidy-access-policy-uuid', + groupAssociations: ['group-uuid'], +}; + +const mockSubsidyAccessPolicyNoGroups = { + uuid: 'test-subsidy-access-policy-uuid', + groupAssociations: null, +}; + +describe('useEnterpriseGroup', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should fetch and return enterprise group', async () => { + jest.spyOn(LmsApiService, 'fetchEnterpriseGroup').mockResolvedValueOnce({ + data: { + enterprise_customer: 'customer-uuid', + name: 'groupidy group', + uuid: 'group-uuid', + }, + }); + + const { result, waitForNextUpdate } = renderHook( + () => useEnterpriseGroup(mockSubsidyAccessPolicy), + { wrapper }, + ); + + await waitForNextUpdate(); + expect(result.current.data).toEqual( + { enterpriseCustomer: 'customer-uuid', name: 'groupidy group', uuid: 'group-uuid' }, + ); + }); + + it('should return null if no group associations are listed', async () => { + jest.spyOn(LmsApiService, 'fetchEnterpriseGroup').mockResolvedValueOnce({ + data: { + enterprise_customer: 'customer-uuid', + name: 'groupidy group', + uuid: 'group-uuid', + }, + }); + + const { result, waitForNextUpdate } = renderHook( + () => useEnterpriseGroup(mockSubsidyAccessPolicyNoGroups), + { wrapper }, + ); + + await waitForNextUpdate(); + expect(result.current.data).toBe(null); + }); +}); diff --git a/src/components/learner-credit-management/data/hooks/useEnterpriseGroup.js b/src/components/learner-credit-management/data/hooks/useEnterpriseGroup.js new file mode 100644 index 0000000000..7a61f897e2 --- /dev/null +++ b/src/components/learner-credit-management/data/hooks/useEnterpriseGroup.js @@ -0,0 +1,28 @@ +import { useQuery } from '@tanstack/react-query'; +import { camelCaseObject } from '@edx/frontend-platform/utils'; + +import { learnerCreditManagementQueryKeys } from '../constants'; +import LmsApiService from '../../../../data/services/LmsApiService'; + +/** + * Retrieves a enterprise group by UUID from the API. + * + * @param {*} queryKey The queryKey from the associated `useQuery` call. + * @returns The enterprise group object + */ +const getEnterpriseGroup = async ({ subsidyAccessPolicy }) => { + if (!subsidyAccessPolicy.groupAssociations || subsidyAccessPolicy.groupAssociations.length === 0) { + return null; + } + const response = await LmsApiService.fetchEnterpriseGroup(subsidyAccessPolicy.groupAssociations[0]); + const enterpriseGroup = camelCaseObject(response.data); + return enterpriseGroup; +}; + +const useEnterpriseGroup = (subsidyAccessPolicy, { queryOptions } = {}) => useQuery({ + queryKey: learnerCreditManagementQueryKeys.group(subsidyAccessPolicy?.uuid), + queryFn: () => getEnterpriseGroup({ subsidyAccessPolicy }), + ...queryOptions, +}); + +export default useEnterpriseGroup; diff --git a/src/data/services/LmsApiService.js b/src/data/services/LmsApiService.js index 686164ec4e..36ebb8dab8 100644 --- a/src/data/services/LmsApiService.js +++ b/src/data/services/LmsApiService.js @@ -420,6 +420,11 @@ class LmsApiService { return response; }; + static fetchEnterpriseGroup = async (groupUuid) => { + const groupEndpoint = `${LmsApiService.enterpriseGroupUrl}${groupUuid}/`; + return LmsApiService.apiClient().get(groupEndpoint); + }; + static inviteEnterpriseLearnersToGroup = async (groupUuid, formData) => { const assignLearnerEndpoint = `${LmsApiService.enterpriseGroupUrl}${groupUuid}/assign_learners/`; return LmsApiService.apiClient().post(assignLearnerEndpoint, formData);