From 8500f4dbf4ec55ccf4338c75cc92d23fd74d0fc9 Mon Sep 17 00:00:00 2001 From: irfanuddinahmad <34648393+irfanuddinahmad@users.noreply.github.com> Date: Thu, 3 Aug 2023 14:17:49 +0500 Subject: [PATCH] feat: ENT-7309 Added budget category based page for learner credit (#1003) * feat: ENT-7309 Added budget category-based page for learner credit Co-authored-by: IrfanUddinAhmad Co-authored-by: zamanafzal --- .../learner-credit-management/BudgetCard.jsx | 175 ++++++++++++++++++ .../LearnerCreditAllocationTable.jsx | 9 + .../MultipleBudgetsPage.jsx | 84 +++++++++ .../MultipleBudgetsPicker.jsx | 38 ++++ .../learner-credit-management/data/hooks.js | 4 + .../data/tests/hooks.test.js | 2 + .../data/tests/utils.test.js | 2 + .../learner-credit-management/data/utils.js | 6 + .../learner-credit-management/index.js | 4 +- .../tests/BudgetCard.test.jsx | 140 ++++++++++++++ .../LearnerCreditAllocationTable.test.jsx | 19 ++ .../tests/MultipleBudgetsPage.test.jsx | 47 +++++ src/utils.js | 7 + 13 files changed, 535 insertions(+), 2 deletions(-) create mode 100644 src/components/learner-credit-management/BudgetCard.jsx create mode 100644 src/components/learner-credit-management/MultipleBudgetsPage.jsx create mode 100644 src/components/learner-credit-management/MultipleBudgetsPicker.jsx create mode 100644 src/components/learner-credit-management/tests/BudgetCard.test.jsx create mode 100644 src/components/learner-credit-management/tests/MultipleBudgetsPage.test.jsx diff --git a/src/components/learner-credit-management/BudgetCard.jsx b/src/components/learner-credit-management/BudgetCard.jsx new file mode 100644 index 0000000000..138113f964 --- /dev/null +++ b/src/components/learner-credit-management/BudgetCard.jsx @@ -0,0 +1,175 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import dayjs from 'dayjs'; +import { + Card, + Button, + Stack, + Row, + Col, + Breadcrumb, +} from '@edx/paragon'; + +import { getCourseProductLineAbbreviation } from '../../utils'; +import { useOfferRedemptions, useOfferSummary } from './data/hooks'; +import LearnerCreditAggregateCards from './LearnerCreditAggregateCards'; +import LearnerCreditAllocationTable from './LearnerCreditAllocationTable'; +import { ROUTE_NAMES } from '../EnterpriseApp/data/constants'; + +const BudgetCard = ({ + offer, + enterpriseUUID, + enterpriseSlug, +}) => { + const { + start, + end, + } = offer; + + const { + isLoading: isLoadingOfferSummary, + offerSummary, + } = useOfferSummary(enterpriseUUID, offer); + + const { + isLoading: isLoadingOfferRedemptions, + offerRedemptions, + fetchOfferRedemptions, + } = useOfferRedemptions(enterpriseUUID, offer?.id); + const [detailPage, setDetailPage] = useState(false); + const [activeLabel, setActiveLabel] = useState(''); + const links = [ + { label: 'Budgets', url: `/${enterpriseSlug}/admin/${ROUTE_NAMES.learnerCredit}` }, + ]; + const formattedStartDate = dayjs(start).format('MMMM D, YYYY'); + const formattedExpirationDate = dayjs(end).format('MMMM D, YYYY'); + const navigateToBudgetRedemptions = (budgetType) => { + setDetailPage(true); + links.push({ label: budgetType, url: `/${enterpriseSlug}/admin/learner-credit` }); + setActiveLabel(budgetType); + }; + + const renderActions = (budgetType) => ( + + ); + + const renderCardHeader = (budgetType) => { + const subtitle = ( +
+ + {formattedStartDate} - {formattedExpirationDate} + +
+ ); + + return ( + + {renderActions(budgetType)} + + )} + /> + ); + }; + + const renderCardSection = (available, spent) => ( + + + + Available + {available} + + + Spent + {spent} + + + + ); + + const renderCardAggregate = () => ( +
+ +
+ ); + + return ( + + + + + + + {!detailPage + ? ( + <> + {renderCardAggregate()} +

Budgets

+ + + + {renderCardHeader('Open Courses Marketplace')} + {renderCardSection(offerSummary?.remainingFunds, offerSummary?.redeemedFundsOcm)} + + + + + + + {renderCardHeader('Executive Education')} + {renderCardSection(offerSummary?.remainingFunds, offerSummary?.redeemedFundsExecEd)} + + + + + ) + : ( + + )} +
+ ); +}; + +BudgetCard.propTypes = { + offer: PropTypes.shape({ + id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + start: PropTypes.string.isRequired, + end: PropTypes.string.isRequired, + }).isRequired, + enterpriseUUID: PropTypes.string.isRequired, + enterpriseSlug: PropTypes.string.isRequired, +}; + +export default BudgetCard; diff --git a/src/components/learner-credit-management/LearnerCreditAllocationTable.jsx b/src/components/learner-credit-management/LearnerCreditAllocationTable.jsx index ab01eb3d40..e463487937 100644 --- a/src/components/learner-credit-management/LearnerCreditAllocationTable.jsx +++ b/src/components/learner-credit-management/LearnerCreditAllocationTable.jsx @@ -17,8 +17,11 @@ const LearnerCreditAllocationTable = ({ tableData, fetchTableData, enterpriseUUID, + budgetType, }) => { const isDesktopTable = useMediaQuery({ minWidth: breakpoints.extraLarge.minWidth }); + const defaultFilter = budgetType ? [{ id: 'courseProductLine', value: budgetType }] : []; + return ( getCourseProductLineText(row.values.courseProductLine), + disableFilters: true, }, ]} initialTableOptions={{ @@ -68,6 +72,7 @@ const LearnerCreditAllocationTable = ({ sortBy: [ { id: 'enrollmentDate', desc: true }, ], + filters: defaultFilter, }} fetchData={fetchTableData} data={tableData.results} @@ -85,6 +90,9 @@ const LearnerCreditAllocationTable = ({ /> ); }; +LearnerCreditAllocationTable.defaultProps = { + budgetType: null, +}; LearnerCreditAllocationTable.propTypes = { enterpriseUUID: PropTypes.string.isRequired, @@ -101,6 +109,7 @@ LearnerCreditAllocationTable.propTypes = { pageCount: PropTypes.number.isRequired, }).isRequired, fetchTableData: PropTypes.func.isRequired, + budgetType: PropTypes.string, }; export default LearnerCreditAllocationTable; diff --git a/src/components/learner-credit-management/MultipleBudgetsPage.jsx b/src/components/learner-credit-management/MultipleBudgetsPage.jsx new file mode 100644 index 0000000000..cec507a231 --- /dev/null +++ b/src/components/learner-credit-management/MultipleBudgetsPage.jsx @@ -0,0 +1,84 @@ +import React, { useContext } from 'react'; +import PropTypes from 'prop-types'; +import { + Stack, + Row, + Col, + Card, + Hyperlink, +} from '@edx/paragon'; +import { connect } from 'react-redux'; +import { Helmet } from 'react-helmet'; +import Hero from '../Hero'; + +import LoadingMessage from '../LoadingMessage'; +import MultipleBudgetsPicker from './MultipleBudgetsPicker'; +import { EnterpriseSubsidiesContext } from '../EnterpriseSubsidiesContext'; + +import { configuration } from '../../config'; + +const PAGE_TITLE = 'Learner Credit'; + +const MultipleBudgetsPage = ({ + enterpriseUUID, + enterpriseSlug, +}) => { + const { offers, isLoading } = useContext(EnterpriseSubsidiesContext); + + if (isLoading) { + return ; + } + + if (offers.length === 0) { + return ( + + + + + + + +

No budgets for your organization

+

+ We were unable to find any budgets for your organization. Please contact + Customer Support if you have questions. +

+ + Contact support + + +
+
+
+
+ ); + } + + return ( + <> + + + + + ); +}; + +const mapStateToProps = state => ({ + enterpriseUUID: state.portalConfiguration.enterpriseId, + enterpriseSlug: state.portalConfiguration.enterpriseSlug, +}); + +MultipleBudgetsPage.propTypes = { + enterpriseUUID: PropTypes.string.isRequired, + enterpriseSlug: PropTypes.string.isRequired, +}; + +export default connect(mapStateToProps)(MultipleBudgetsPage); diff --git a/src/components/learner-credit-management/MultipleBudgetsPicker.jsx b/src/components/learner-credit-management/MultipleBudgetsPicker.jsx new file mode 100644 index 0000000000..a7b4bc814e --- /dev/null +++ b/src/components/learner-credit-management/MultipleBudgetsPicker.jsx @@ -0,0 +1,38 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + Stack, + Row, + Col, +} from '@edx/paragon'; + +import BudgetCard from './BudgetCard'; + +const MultipleBudgetsPicker = ({ + offers, + enterpriseUUID, + enterpriseSlug, +}) => ( + + + + {offers.map(offer => ( + + ))} + + + +); + +MultipleBudgetsPicker.propTypes = { + offers: PropTypes.arrayOf(PropTypes.shape()).isRequired, + enterpriseUUID: PropTypes.string.isRequired, + enterpriseSlug: PropTypes.string.isRequired, +}; + +export default MultipleBudgetsPicker; diff --git a/src/components/learner-credit-management/data/hooks.js b/src/components/learner-credit-management/data/hooks.js index 45b3d73934..7ff74aaa69 100644 --- a/src/components/learner-credit-management/data/hooks.js +++ b/src/components/learner-credit-management/data/hooks.js @@ -65,12 +65,16 @@ const applySortByToOptions = (sortBy, options) => { const applyFiltersToOptions = (filters, options) => { const userSearchQuery = filters?.find(filter => filter.id === 'userEmail')?.value; const courseTitleSearchQuery = filters?.find(filter => filter.id === 'courseTitle')?.value; + const courseProductLineSearchQuery = filters?.find(filter => filter.id === 'courseProductLine')?.value; if (userSearchQuery) { Object.assign(options, { search: userSearchQuery }); } if (courseTitleSearchQuery) { Object.assign(options, { searchCourse: courseTitleSearchQuery }); } + if (courseProductLineSearchQuery) { + Object.assign(options, { courseProductLine: courseProductLineSearchQuery }); + } }; export const useOfferRedemptions = (enterpriseUUID, offerId) => { diff --git a/src/components/learner-credit-management/data/tests/hooks.test.js b/src/components/learner-credit-management/data/tests/hooks.test.js index b6bf511d57..fa03c60f9e 100644 --- a/src/components/learner-credit-management/data/tests/hooks.test.js +++ b/src/components/learner-credit-management/data/tests/hooks.test.js @@ -68,6 +68,8 @@ describe('useOfferSummary', () => { const expectedResult = { totalFunds: 5000, redeemedFunds: 200, + redeemedFundsExecEd: NaN, + redeemedFundsOcm: NaN, remainingFunds: 4800, percentUtilized: 0.04, }; diff --git a/src/components/learner-credit-management/data/tests/utils.test.js b/src/components/learner-credit-management/data/tests/utils.test.js index 2b82930015..88773efbcc 100644 --- a/src/components/learner-credit-management/data/tests/utils.test.js +++ b/src/components/learner-credit-management/data/tests/utils.test.js @@ -16,6 +16,8 @@ describe('transformOfferSummary', () => { expect(transformOfferSummary(offerSummary)).toEqual({ totalFunds: 1, redeemedFunds: 1, + redeemedFundsExecEd: NaN, + redeemedFundsOcm: NaN, remainingFunds: 0.0, percentUtilized: 1.0, }); diff --git a/src/components/learner-credit-management/data/utils.js b/src/components/learner-credit-management/data/utils.js index 7a9101ba16..22cff6cd3a 100644 --- a/src/components/learner-credit-management/data/utils.js +++ b/src/components/learner-credit-management/data/utils.js @@ -15,11 +15,15 @@ export const transformOfferSummary = (offerSummary) => { const totalFunds = offerSummary.maxDiscount && parseFloat(offerSummary.maxDiscount); let redeemedFunds = offerSummary.amountOfOfferSpent && parseFloat(offerSummary.amountOfOfferSpent); + let redeemedFundsOcm = offerSummary.amountOfferSpentOcm && parseFloat(offerSummary.amountOfferSpentOcm); + let redeemedFundsExecEd = offerSummary.amountOfferSpentExecEd && parseFloat(offerSummary.amountOfferSpentExecEd); // cap redeemed funds at the maximum funds available (`maxDiscount`), if applicable, so we // don't display redeemed funds > funds available. if (totalFunds) { redeemedFunds = Math.min(redeemedFunds, totalFunds); + redeemedFundsOcm = Math.min(redeemedFundsOcm, totalFunds); + redeemedFundsExecEd = Math.min(redeemedFundsExecEd, totalFunds); } let remainingFunds = offerSummary.remainingBalance && parseFloat(offerSummary.remainingBalance); @@ -37,6 +41,8 @@ export const transformOfferSummary = (offerSummary) => { return { totalFunds, redeemedFunds, + redeemedFundsOcm, + redeemedFundsExecEd, remainingFunds, percentUtilized, }; diff --git a/src/components/learner-credit-management/index.js b/src/components/learner-credit-management/index.js index e691d21a11..271f4453ed 100644 --- a/src/components/learner-credit-management/index.js +++ b/src/components/learner-credit-management/index.js @@ -1,3 +1,3 @@ -import LearnerCreditManagement from './LearnerCreditManagement'; +import MultipleBudgetsPage from './MultipleBudgetsPage'; -export default LearnerCreditManagement; +export default MultipleBudgetsPage; diff --git a/src/components/learner-credit-management/tests/BudgetCard.test.jsx b/src/components/learner-credit-management/tests/BudgetCard.test.jsx new file mode 100644 index 0000000000..afa6c60620 --- /dev/null +++ b/src/components/learner-credit-management/tests/BudgetCard.test.jsx @@ -0,0 +1,140 @@ +/* eslint-disable react/prop-types */ +import React from 'react'; +import { Provider } from 'react-redux'; +import thunk from 'redux-thunk'; +import userEvent from '@testing-library/user-event'; +import configureMockStore from 'redux-mock-store'; +import dayjs from 'dayjs'; +import { + screen, + render, + waitFor, +} from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; + +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import BudgetCard from '../BudgetCard'; +import { useOfferSummary, useOfferRedemptions } from '../data/hooks'; + +jest.mock('../data/hooks'); +useOfferSummary.mockReturnValue({ + isLoading: false, + offerSummary: null, +}); +useOfferRedemptions.mockReturnValue({ + isLoading: false, + offerRedemptions: { + itemCount: 0, + pageCount: 0, + results: [], + }, + fetchOfferRedemptions: jest.fn(), +}); + +const mockStore = configureMockStore([thunk]); +const getMockStore = store => mockStore(store); +const enterpriseId = 'test-enterprise'; +const enterpriseUUID = '1234'; +const initialStore = { + portalConfiguration: { + enterpriseId, + }, +}; +const store = getMockStore({ ...initialStore }); + +const mockEnterpriseOfferId = '123'; +const mockEnterpriseOfferEnrollmentId = 456; + +const mockOfferDisplayName = 'Test Enterprise Offer'; +const mockOfferSummary = { + totalFunds: 5000, + redeemedFunds: 200, + remainingFunds: 4800, + percentUtilized: 0.04, +}; + +const BudgetCardWrapper = ({ ...rest }) => ( + + + + + +); + +describe('', () => { + describe('with enterprise offer', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('displays correctly', () => { + const mockOffer = { + id: mockEnterpriseOfferId, + name: mockOfferDisplayName, + start: '2022-01-01', + end: '2023-01-01', + }; + const mockOfferRedemption = { + created: '2022-02-01', + enterpriseEnrollmentId: mockEnterpriseOfferEnrollmentId, + }; + useOfferSummary.mockReturnValue({ + isLoading: false, + offerSummary: mockOfferSummary, + }); + useOfferRedemptions.mockReturnValue({ + isLoading: false, + offerRedemptions: { + results: [mockOfferRedemption], + itemCount: 1, + pageCount: 1, + }, + fetchOfferRedemptions: jest.fn(), + }); + render(); + expect(screen.getByText('Open Courses Marketplace')); + expect(screen.getByText('Executive Education')); + expect(screen.getByText(`$${mockOfferSummary.redeemedFunds.toLocaleString()}`)); + const formattedString = `${dayjs(mockOffer.start).format('MMMM D, YYYY')} - ${dayjs(mockOffer.end).format('MMMM D, YYYY')}`; + const elementsWithTestId = screen.getAllByTestId('offer-date'); + const firstElementWithTestId = elementsWithTestId[0]; + expect(firstElementWithTestId).toHaveTextContent(formattedString); + }); + + it('displays table on clicking view budget', async () => { + const mockOffer = { + id: mockEnterpriseOfferId, + name: mockOfferDisplayName, + start: '2022-01-01', + end: '2023-01-01', + }; + useOfferSummary.mockReturnValue({ + isLoading: false, + offerSummary: mockOfferSummary, + }); + useOfferRedemptions.mockReturnValue({ + isLoading: false, + offerRedemptions: { + itemCount: 0, + pageCount: 0, + results: [], + }, + fetchOfferRedemptions: jest.fn(), + }); + render(); + const elementsWithTestId = screen.getAllByTestId('view-budget'); + const firstElementWithTestId = elementsWithTestId[0]; + await waitFor(() => userEvent.click(firstElementWithTestId)); + expect(screen.getByText('Filters')); + expect(screen.getByText('No results found')); + }); + }); +}); diff --git a/src/components/learner-credit-management/tests/LearnerCreditAllocationTable.test.jsx b/src/components/learner-credit-management/tests/LearnerCreditAllocationTable.test.jsx index 00ebf8f3fc..fb79748c70 100644 --- a/src/components/learner-credit-management/tests/LearnerCreditAllocationTable.test.jsx +++ b/src/components/learner-credit-management/tests/LearnerCreditAllocationTable.test.jsx @@ -18,6 +18,7 @@ describe('', () => { const props = { enterpriseUUID: 'test-enterprise-id', isLoading: false, + budgetType: 'OCM', tableData: { results: [{ userEmail: 'test@example.com', @@ -47,4 +48,22 @@ describe('', () => { })); expect(screen.getByText('February', { exact: false })); }); + it('renders with empty table data', () => { + const props = { + enterpriseUUID: 'test-enterprise-id', + isLoading: false, + budgetType: 'OCM', + tableData: { + results: [], + itemCount: 0, + pageCount: 0, + }, + fetchTableData: jest.fn(), + }; + props.fetchTableData.mockReturnValue(props.tableData); + + render(); + + expect(screen.getByText('No results found', { exact: false })); + }); }); diff --git a/src/components/learner-credit-management/tests/MultipleBudgetsPage.test.jsx b/src/components/learner-credit-management/tests/MultipleBudgetsPage.test.jsx new file mode 100644 index 0000000000..8b22a9d4ec --- /dev/null +++ b/src/components/learner-credit-management/tests/MultipleBudgetsPage.test.jsx @@ -0,0 +1,47 @@ +/* eslint-disable react/prop-types */ +import React from 'react'; +import { Provider } from 'react-redux'; +import thunk from 'redux-thunk'; +import configureMockStore from 'redux-mock-store'; +import { + screen, + render, +} from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; + +import { EnterpriseSubsidiesContext } from '../../EnterpriseSubsidiesContext'; +import MultipleBudgetsPage from '../MultipleBudgetsPage'; + +const mockStore = configureMockStore([thunk]); +const getMockStore = store => mockStore(store); +const enterpriseId = 'test-enterprise'; +const initialStore = { + portalConfiguration: { + enterpriseId, + }, +}; +const store = getMockStore({ ...initialStore }); +const enterpriseUUID = '1234'; + +const defaultEnterpriseSubsidiesContextValue = { + offers: [], +}; + +const MultipleBudgetsPageWrapper = ({ + enterpriseSubsidiesContextValue = defaultEnterpriseSubsidiesContextValue, + ...rest +}) => ( + + + + + +); + +describe('', () => { + it('No budgets for your organization', () => { + render(); + expect(screen.getByText('No budgets for your organization')); + expect(screen.getByText('Contact support')); + }); +}); diff --git a/src/utils.js b/src/utils.js index 44fe64bc94..9582955d3c 100644 --- a/src/utils.js +++ b/src/utils.js @@ -406,6 +406,12 @@ const getCourseProductLineText = (courseProductLine) => { return courseProductLineText; }; +const getCourseProductLineAbbreviation = (courseProductLine) => { + let courseProductLineText = ''; + courseProductLineText = courseProductLine === 'Open Courses Marketplace' ? 'OCM' : 'Executive Education'; + return courseProductLineText; +}; + export { camelCaseDict, camelCaseDictArray, @@ -440,4 +446,5 @@ export { pollAsync, isNotValidNumberString, getCourseProductLineText, + getCourseProductLineAbbreviation, };