From 3cf71d4a7a146cb787491c967b01132d792fef90 Mon Sep 17 00:00:00 2001 From: Kira Miller <31229189+kiram15@users.noreply.github.com> Date: Thu, 9 May 2024 09:32:21 -0600 Subject: [PATCH] feat: adding in extra info to invite learners modal (#1216) * feat: adding in extra modal info * fix: PR requests * fix: more PR requests --- .../BudgetDetail.jsx | 53 +++++++++ .../BudgetDetailPageOverviewAvailability.jsx | 75 +------------ .../BudgetOverviewContent.jsx | 2 +- .../data/hooks/index.js | 1 + .../data/hooks/useContentMetadata.js | 19 ++++ .../InviteMembersModalWrapper.jsx | 1 + .../invite-modal/InviteModalBudgetCard.jsx | 104 ++++++++++++++++++ .../invite-modal/InviteModalContent.jsx | 14 ++- .../InviteModalMembershipInfo.jsx | 91 +++++++++++++++ .../invite-modal/InviteModalPermissions.jsx | 56 ++++++++++ .../tests/InviteMemberModal.test.jsx | 61 ++++++++-- .../styles/index.scss | 5 + .../services/EnterpriseCatalogApiService.js | 5 + src/data/services/LmsApiService.js | 7 +- 14 files changed, 404 insertions(+), 90 deletions(-) create mode 100644 src/components/learner-credit-management/BudgetDetail.jsx create mode 100644 src/components/learner-credit-management/data/hooks/useContentMetadata.js create mode 100644 src/components/learner-credit-management/invite-modal/InviteModalBudgetCard.jsx create mode 100644 src/components/learner-credit-management/invite-modal/InviteModalMembershipInfo.jsx create mode 100644 src/components/learner-credit-management/invite-modal/InviteModalPermissions.jsx diff --git a/src/components/learner-credit-management/BudgetDetail.jsx b/src/components/learner-credit-management/BudgetDetail.jsx new file mode 100644 index 0000000000..e8fd8822a3 --- /dev/null +++ b/src/components/learner-credit-management/BudgetDetail.jsx @@ -0,0 +1,53 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { ProgressBar, Stack } from '@edx/paragon'; + +import { formatPrice } from './data'; +import { BUDGET_STATUSES } from '../EnterpriseApp/data/constants'; + +const BudgetDetail = ({ + available, utilized, limit, status, +}) => { + const currentProgressBarLimit = (available / limit) * 100; + + if (status === BUDGET_STATUSES.expired) { + return ( + +

Spent

+ + {formatPrice(utilized)} + + Unspent {formatPrice(available)} + + +
+ ); + } + + return ( + +

Available

+ + {formatPrice(available)} + + Utilized {formatPrice(utilized)} + + + + + + {formatPrice(limit)} limit + + +
+ ); +}; + +BudgetDetail.propTypes = { + available: PropTypes.number.isRequired, + utilized: PropTypes.number.isRequired, + limit: PropTypes.number.isRequired, + status: PropTypes.string.isRequired, +}; + +export default BudgetDetail; diff --git a/src/components/learner-credit-management/BudgetDetailPageOverviewAvailability.jsx b/src/components/learner-credit-management/BudgetDetailPageOverviewAvailability.jsx index c35803f23e..b8a7e0490d 100644 --- a/src/components/learner-credit-management/BudgetDetailPageOverviewAvailability.jsx +++ b/src/components/learner-credit-management/BudgetDetailPageOverviewAvailability.jsx @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import { generatePath, useParams, Link } from 'react-router-dom'; import { - Button, Col, Hyperlink, ProgressBar, Row, Stack, + Button, Col, Hyperlink, Row, Stack, } from '@edx/paragon'; import { Add } from '@edx/paragon/icons'; import { sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils'; @@ -12,82 +12,13 @@ import { FormattedMessage } from '@edx/frontend-platform/i18n'; import { configuration } from '../../config'; import { BudgetDetailPageContext } from './BudgetDetailPageWrapper'; import { - formatPrice, - useBudgetId, - useSubsidyAccessPolicy, - useEnterpriseCustomer, - useEnterpriseGroup, + useBudgetId, useSubsidyAccessPolicy, useEnterpriseCustomer, useEnterpriseGroup, } from './data'; import EVENT_NAMES from '../../eventTracking'; import { LEARNER_CREDIT_ROUTE } from './constants'; import { BUDGET_STATUSES } from '../EnterpriseApp/data/constants'; import isLmsBudget from './utils'; - -const BudgetDetail = ({ - available, utilized, limit, status, -}) => { - const currentProgressBarLimit = (available / limit) * 100; - - if (status === BUDGET_STATUSES.expired) { - return ( - -

Spent

- - {formatPrice(utilized)} - - - - -
- ); - } - - return ( - -

- -

- - {formatPrice(available)} - - - - - - - - - - -
- ); -}; - -BudgetDetail.propTypes = { - available: PropTypes.number.isRequired, - utilized: PropTypes.number.isRequired, - limit: PropTypes.number.isRequired, - status: PropTypes.string.isRequired, -}; +import BudgetDetail from './BudgetDetail'; const BudgetActions = ({ budgetId, diff --git a/src/components/learner-credit-management/BudgetOverviewContent.jsx b/src/components/learner-credit-management/BudgetOverviewContent.jsx index e0ab5dc7fb..d5a8f93d28 100644 --- a/src/components/learner-credit-management/BudgetOverviewContent.jsx +++ b/src/components/learner-credit-management/BudgetOverviewContent.jsx @@ -66,7 +66,7 @@ const BudgetOverviewContent = ({ } return ( - +

{budgetDisplayName}

{ + const response = await EnterpriseCatalogApiService.fetchEnterpriseCatalogMetadata({ catalogUuid }); + const contentMetadata = camelCaseObject(response.data); + return contentMetadata; +}; + +const useContentMetadata = (catalogUuid, { queryOptions } = {}) => useQuery({ + queryKey: learnerCreditManagementQueryKeys.group(catalogUuid), + queryFn: () => getContentMetadata({ catalogUuid }), + ...queryOptions, +}); + +export default useContentMetadata; diff --git a/src/components/learner-credit-management/invite-modal/InviteMembersModalWrapper.jsx b/src/components/learner-credit-management/invite-modal/InviteMembersModalWrapper.jsx index 5819c41a7b..7d720705f7 100644 --- a/src/components/learner-credit-management/invite-modal/InviteMembersModalWrapper.jsx +++ b/src/components/learner-credit-management/invite-modal/InviteMembersModalWrapper.jsx @@ -111,6 +111,7 @@ const InviteMembersModalWrapper = ({ > { + const { subsidyAccessPolicyId, enterpriseOfferId } = useBudgetId(); + const { data: subsidyAccessPolicy } = useSubsidyAccessPolicy(subsidyAccessPolicyId); + const { data } = useEnterpriseGroupLearners(subsidyAccessPolicy.groupAssociations[0]); + + const memberSubtitle = data?.count ? `${makePlural(data?.count, 'current member')}` : ''; + const budgetType = (enterpriseOfferId !== null) ? BUDGET_TYPES.ecommerce : BUDGET_TYPES.policy; + + const { isLoading: isLoadingSubsidySummary, subsidySummary } = useSubsidySummaryAnalyticsApi( + enterpriseUUID, + enterpriseOfferId, + budgetType, + ); + + const { isLoading: isLoadingEnterpriseOffer, data: enterpriseOfferMetadata } = useEnterpriseOffer(enterpriseOfferId); + + const policyOrOfferId = subsidyAccessPolicyId || enterpriseOfferId; + const { + budgetDisplayName, + budgetTotalSummary, + status, + badgeVariant, + term, + date, + isAssignable, + } = useBudgetDetailHeaderData({ + subsidyAccessPolicy, + subsidySummary, + budgetId: policyOrOfferId, + enterpriseOfferMetadata, + isTopDownAssignmentEnabled: enterpriseFeatures.topDownAssignmentRealTimeLcm, + }); + + if (!subsidyAccessPolicy && (isLoadingSubsidySummary || isLoadingEnterpriseOffer)) { + return ( +
+ + Loading budget header data +
+ ); + } + + const { available, utilized, limit } = budgetTotalSummary; + return ( + + + + +

{budgetDisplayName}

+

{memberSubtitle}

+ + + + + +
+
+
+ ); +}; + +const mapStateToProps = state => ({ + enterpriseUUID: state.portalConfiguration.enterpriseId, + enterpriseFeatures: state.portalConfiguration.enterpriseFeatures, +}); + +InviteModalBudgetCard.propTypes = { + enterpriseUUID: PropTypes.string.isRequired, + enterpriseFeatures: PropTypes.shape({ + topDownAssignmentRealTimeLcm: PropTypes.bool, + }).isRequired, +}; + +export default connect(mapStateToProps)(InviteModalBudgetCard); diff --git a/src/components/learner-credit-management/invite-modal/InviteModalContent.jsx b/src/components/learner-credit-management/invite-modal/InviteModalContent.jsx index 96ec5768b2..8f9b12ac56 100644 --- a/src/components/learner-credit-management/invite-modal/InviteModalContent.jsx +++ b/src/components/learner-credit-management/invite-modal/InviteModalContent.jsx @@ -11,8 +11,11 @@ import InviteModalSummary from './InviteModalSummary'; import { EMAIL_ADDRESSES_INPUT_VALUE_DEBOUNCE_DELAY, INPUT_TYPE, isInviteEmailAddressesInputValueValid } from '../cards/data'; import FileUpload from './FileUpload'; import InviteModalInputFeedback from './InviteModalInputFeedback'; +import InviteModalMembershipInfo from './InviteModalMembershipInfo'; +import InviteModalBudgetCard from './InviteModalBudgetCard'; +import InviteModalPermissions from './InviteModalPermissions'; -const InviteModalContent = ({ onEmailAddressesChange }) => { +const InviteModalContent = ({ onEmailAddressesChange, subsidyAccessPolicy }) => { const [learnerEmails, setLearnerEmails] = useState([]); const [inputType, setInputType] = useState('email'); const [emailAddressesInputValue, setEmailAddressesInputValue] = useState(''); @@ -58,6 +61,7 @@ const InviteModalContent = ({ onEmailAddressesChange }) => { return (

Invite members to this budget

+

Send invite to

@@ -91,12 +95,13 @@ const InviteModalContent = ({ onEmailAddressesChange }) => { setEmailAddressesInputValue={setEmailAddressesInputValue} /> )} +

Details

- + +
+
@@ -105,6 +110,7 @@ const InviteModalContent = ({ onEmailAddressesChange }) => { InviteModalContent.propTypes = { onEmailAddressesChange: PropTypes.func.isRequired, + subsidyAccessPolicy: PropTypes.shape(), }; export default InviteModalContent; diff --git a/src/components/learner-credit-management/invite-modal/InviteModalMembershipInfo.jsx b/src/components/learner-credit-management/invite-modal/InviteModalMembershipInfo.jsx new file mode 100644 index 0000000000..347acebdea --- /dev/null +++ b/src/components/learner-credit-management/invite-modal/InviteModalMembershipInfo.jsx @@ -0,0 +1,91 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Collapsible } from '@edx/paragon'; + +const InviteModalMembershipInfo = ({ subsidyAccessPolicy }) => { + const { + policyType, spendLimit, subsidyExpirationDatetime, + } = subsidyAccessPolicy; + const expiration = new Date(subsidyExpirationDatetime).toLocaleDateString(); + const dynamicListBullets = () => { + if (policyType === 'PerLearnerEnrollmentCreditAccessPolicy') { + return ( +
  • + Member permissions for this budget include browsing this budget's + catalog and enroll by spending from this budget's available balance. + Member spending is set to first come, first serve: there is no limit on the + funds any individual member can spend. +
  • + ); + } if (spendLimit !== null) { + return ( +
  • + Member permissions for this budget include browsing this budget's catalog and + enroll by spending from this budget's available balance. Per member spending is limited to + ${spendLimit}: after this maximum has been exceeded, members can still browse but no + longer enroll in courses. +
  • + ); + } + return ''; + }; + return ( + <> +
    How membership works
    + +
      +
    • Newly invited members are immediately notified by email.
    • +
    • + Members must accept their invitation to browse and enroll within 90 days + by registering for an edX account or logging in. After 90 days, the invitation + will expire and members that do not accept are automatically purged. +
    • +
    • Members receive automated reminder emails during the 90 day acceptance window.
    • +
    +
    + +
      +
    • + Once their invitation has been accepted, member permissions are automatically applied + to the member's Learner Portal experience until the budget expires on {expiration}. +
    • + {dynamicListBullets()} +
    • + This budget's catalog and expiration date are visible to members in the Learner Portal. Members cannot + see this budget's available balance or any other information related to this budget + (such as its name or other members associated with it). +
    • +
    +
    + +
      +
    • Members can be removed at any time from this budget's Members tab.
    • +
    • + Removing members immediately strips the associated member permissions away + from the member's Learner Portal experience. +
    • +
    • Any courses the member previously enrolled in using this budget are not affected by removal.
    • +
    +
    + + ); +}; + +InviteModalMembershipInfo.propTypes = { + subsidyAccessPolicy: PropTypes.shape({ + policyType: PropTypes.string.isRequired, + spendLimit: PropTypes.number, + subsidyExpirationDatetime: PropTypes.string.isRequired, + }).isRequired, +}; + +export default InviteModalMembershipInfo; diff --git a/src/components/learner-credit-management/invite-modal/InviteModalPermissions.jsx b/src/components/learner-credit-management/invite-modal/InviteModalPermissions.jsx new file mode 100644 index 0000000000..cf68ff258d --- /dev/null +++ b/src/components/learner-credit-management/invite-modal/InviteModalPermissions.jsx @@ -0,0 +1,56 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Card, Icon, Stack } from '@edx/paragon'; +import { Check } from '@edx/paragon/icons'; +import useContentMetadata from '../data/hooks/useContentMetadata'; + +const InviteModalPermissions = ({ subsidyAccessPolicy }) => { + const { catalogUuid, policyType, spendLimit } = subsidyAccessPolicy; + const { data } = useContentMetadata(catalogUuid); + + const getPolicyType = () => { + if (policyType === 'PerLearnerEnrollmentCreditAccessPolicy') { + return 'First come, first served'; + } if (spendLimit !== null) { + return `Per member spend limit: $${spendLimit}`; + } + return ''; + }; + + return ( + <> +
    Member permissions
    +

    All members of this budget can:

    + + + + + Browse this budget's catalog +

    {data?.count} courses

    +
    + +
    +
    + + + + Spend from this budget to enroll +

    {getPolicyType()}

    +
    + +
    +
    +
    + + ); +}; + +InviteModalPermissions.propTypes = { + subsidyAccessPolicy: PropTypes.shape({ + catalogUuid: PropTypes.string.isRequired, + policyType: PropTypes.string.isRequired, + spendLimit: PropTypes.number, + }).isRequired, +}; + +export default InviteModalPermissions; diff --git a/src/components/learner-credit-management/invite-modal/tests/InviteMemberModal.test.jsx b/src/components/learner-credit-management/invite-modal/tests/InviteMemberModal.test.jsx index e40de23334..32a58a6be9 100644 --- a/src/components/learner-credit-management/invite-modal/tests/InviteMemberModal.test.jsx +++ b/src/components/learner-credit-management/invite-modal/tests/InviteMemberModal.test.jsx @@ -2,6 +2,9 @@ import React from 'react'; import { fireEvent, render, screen, waitFor, } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import thunk from 'redux-thunk'; +import configureMockStore from 'redux-mock-store'; import userEvent from '@testing-library/user-event'; import '@testing-library/jest-dom/extend-expect'; import { QueryClientProvider } from '@tanstack/react-query'; @@ -9,7 +12,9 @@ import { IntlProvider } from '@edx/frontend-platform/i18n'; import { BudgetDetailPageContext } from '../../BudgetDetailPageWrapper'; import LmsApiService from '../../../../data/services/LmsApiService'; -import { useBudgetId, useSubsidyAccessPolicy } from '../../data'; +import { + useBudgetId, useEnterpriseGroupLearners, useSubsidyAccessPolicy, useContentMetadata, +} from '../../data'; import { EMAIL_ADDRESSES_INPUT_VALUE_DEBOUNCE_DELAY } from '../../cards/data'; import { queryClient } from '../../../test/testUtils'; @@ -20,13 +25,31 @@ jest.mock('@tanstack/react-query', () => ({ ...jest.requireActual('@tanstack/react-query'), useQueryClient: jest.fn(), })); - jest.mock('../../data', () => ({ ...jest.requireActual('../../data'), useBudgetId: jest.fn(), useSubsidyAccessPolicy: jest.fn(), + useEnterpriseGroupLearners: jest.fn(), + useContentMetadata: jest.fn(), })); jest.mock('../../../../data/services/LmsApiService'); +jest.mock('../../../../data/services/EnterpriseCatalogApiService'); + +const mockStore = configureMockStore([thunk]); +const getMockStore = store => mockStore(store); +const enterpriseSlug = 'test-enterprise'; +const enterpriseUUID = '1234'; +const initialStoreState = { + portalConfiguration: { + enterpriseId: enterpriseUUID, + enterpriseSlug, + enableLearnerPortal: true, + enterpriseFeatures: { + topDownAssignmentRealTimeLcm: true, + enterpriseGroupsV1: true, + }, + }, +}; const mockSubsidyAccessPolicy = { uuid: 'test-subsidy-access-policy-uuid', @@ -58,16 +81,22 @@ const defaultProps = { }; const InviteModalWrapper = ({ + initialState = initialStoreState, budgetDetailPageContextValue = defaultBudgetDetailPageContextValue, -}) => ( - - - - - - - -); +}) => { + const store = getMockStore({ ...initialState }); + return ( + + + + + + + + + + ); +}; describe('', () => { beforeEach(() => { @@ -76,6 +105,8 @@ describe('', () => { data: mockSubsidyAccessPolicy, isLoading: false, }); + useContentMetadata.mockReturnValue({ data: { count: 5280 } }); + useEnterpriseGroupLearners.mockReturnValue({ data: { count: 3 } }); }); afterEach(() => { @@ -87,7 +118,15 @@ describe('', () => { expect(screen.getByText('New members')).toBeInTheDocument(); expect(screen.getByText('Invite members to this budget')).toBeInTheDocument(); expect(screen.getByText('Member email addresses')).toBeInTheDocument(); + expect(screen.getByText('Members are invited')).toBeInTheDocument(); + expect(screen.getByText('Newly invited members are immediately notified by email.')).toBeInTheDocument(); + expect(screen.getByText('Members can browse and learn')).toBeInTheDocument(); + expect(screen.getByText('Managing members')).toBeInTheDocument(); + // some dropdowns shouldn't be expanded + expect(screen.queryByText('Members can be removed at any time from this budget\'s Members tab.')).not.toBeInTheDocument(); expect(screen.getByText('Details')).toBeInTheDocument(); + expect(screen.getByText('Member permissions')).toBeInTheDocument(); + expect(screen.getByText('Browse this budget\'s catalog')).toBeInTheDocument(); }); it('allows manual input of emails', async () => { render(); diff --git a/src/components/learner-credit-management/styles/index.scss b/src/components/learner-credit-management/styles/index.scss index 34cdd1ec44..defd68bd76 100644 --- a/src/components/learner-credit-management/styles/index.scss +++ b/src/components/learner-credit-management/styles/index.scss @@ -43,4 +43,9 @@ padding: 1rem; margin-top: 2rem; border: 1px solid $info-500; +} + +.budget-overview-card { + border-radius: 0; + box-shadow: none; } \ No newline at end of file diff --git a/src/data/services/EnterpriseCatalogApiService.js b/src/data/services/EnterpriseCatalogApiService.js index bdb632ff52..a7843e53b4 100644 --- a/src/data/services/EnterpriseCatalogApiService.js +++ b/src/data/services/EnterpriseCatalogApiService.js @@ -14,6 +14,11 @@ class EnterpriseCatalogApiService { static highlightSetUrl = `${EnterpriseCatalogApiService.baseUrl}/highlight-sets-admin/`; + static fetchEnterpriseCatalogMetadata({ catalogUuid }) { + const url = `${EnterpriseCatalogApiService.baseUrl}/enterprise-catalogs/${catalogUuid}/get_content_metadata/`; + return EnterpriseCatalogApiService.apiClient().get(url); + } + static fetchApplicableCatalogs({ enterpriseId, courseRunIds }) { // This API call will *only* obtain the enterprise's catalogs whose // catalog queries return/contain the specified courseRunIds. diff --git a/src/data/services/LmsApiService.js b/src/data/services/LmsApiService.js index d2aad0ae2d..c0bd742b04 100644 --- a/src/data/services/LmsApiService.js +++ b/src/data/services/LmsApiService.js @@ -438,8 +438,11 @@ class LmsApiService { }; static fetchEnterpriseGroupLearners = async (groupUuid, options) => { - const queryParams = new URLSearchParams(options); - const enterpriseGroupLearnersEndpoint = `${LmsApiService.enterpriseGroupUrl}${groupUuid}/learners?${queryParams.toString()}`; + let enterpriseGroupLearnersEndpoint = `${LmsApiService.enterpriseGroupUrl}${groupUuid}/learners`; + if (options) { + const queryParams = new URLSearchParams(options); + enterpriseGroupLearnersEndpoint = `${LmsApiService.enterpriseGroupUrl}${groupUuid}/learners?${queryParams.toString()}`; + } return LmsApiService.apiClient().get(enterpriseGroupLearnersEndpoint); };