Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: feat: Conditionally filter the budget enrollments #1028

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/components/EnterpriseApp/EnterpriseAppRoutes.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import { ROUTE_NAMES } from './data/constants';
import BulkEnrollmentResultsDownloadPage from '../BulkEnrollmentResultsDownloadPage';
import LearnerCreditManagement from '../learner-credit-management';
import BudgetDetailPage from '../learner-credit-management/BudgetDetailPage';
import { EnterpriseSubsidiesContext } from '../EnterpriseSubsidiesContext';
import ContentHighlights from '../ContentHighlights';

Expand Down Expand Up @@ -105,6 +106,11 @@
/>
)}

<Route

Check failure on line 109 in src/components/EnterpriseApp/EnterpriseAppRoutes.jsx

View workflow job for this annotation

GitHub Actions / tests

Expected indentation of 6 space characters but found 8
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] Given this Route and the above Route both relate to the same Learner Credit Management (LCM) feature, I'm wondering if this component should be defining a single parent route for the overall LCM feature, and then the component passed to the parent route has its owned rendered sub-routes for the LCM-specific routes. That way, EnterpriseAppRoutes, doesn't really need any knowledge of the detail route.

Related, as is, this LCM detail page route would be available even when the LCM overview page route (defined above) is not since it isn't relying on the canManageLearnerCredit variable. If the overview page route isn't available; neither should the detail page route.

exact

Check failure on line 110 in src/components/EnterpriseApp/EnterpriseAppRoutes.jsx

View workflow job for this annotation

GitHub Actions / tests

Expected indentation of 10 space characters but found 8
path={`${baseUrl}/admin/${ROUTE_NAMES.learnerCredit}/:id`}

Check failure on line 111 in src/components/EnterpriseApp/EnterpriseAppRoutes.jsx

View workflow job for this annotation

GitHub Actions / tests

Expected indentation of 10 space characters but found 8
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: perhaps :budgetId to be slightly more explicit?

component={BudgetDetailPage}

Check failure on line 112 in src/components/EnterpriseApp/EnterpriseAppRoutes.jsx

View workflow job for this annotation

GitHub Actions / tests

Expected indentation of 10 space characters but found 8
/>

Check failure on line 113 in src/components/EnterpriseApp/EnterpriseAppRoutes.jsx

View workflow job for this annotation

GitHub Actions / tests

The closing bracket must be aligned with the line containing the opening tag (expected column 9)
{enableContentHighlightsPage && (
<Route
path={`${baseUrl}/admin/${ROUTE_NAMES.contentHighlights}`}
Expand Down
87 changes: 27 additions & 60 deletions src/components/learner-credit-management/BudgetCard-V2.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import dayjs from 'dayjs';
import {
Expand All @@ -7,58 +7,45 @@
Stack,
Row,
Col,
Breadcrumb,
} from '@edx/paragon';

import { useOfferRedemptions, useOfferSummary } from './data/hooks';
import { useHistory } from 'react-router-dom';
import { useOfferSummary } from './data/hooks';
import LearnerCreditAggregateCards from './LearnerCreditAggregateCards';
import LearnerCreditAllocationTable from './LearnerCreditAllocationTable';
import { ROUTE_NAMES } from '../EnterpriseApp/data/constants';
import { ROUTE_NAMES } from "../EnterpriseApp/data/constants";

Check failure on line 15 in src/components/learner-credit-management/BudgetCard-V2.jsx

View workflow job for this annotation

GitHub Actions / tests

Strings must use singlequote

const BudgetCard = ({
offer,
enterpriseUUID,
enterpriseSlug,
enableLearnerPortal,
}) => {
const {
start,
end,
} = offer;
const history = useHistory();

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 navigateToBudgetRedemptions = (id) => {
history.push(`/${enterpriseSlug}/admin/${ROUTE_NAMES.learnerCredit}/${id}`);
};

const renderActions = (budgetType) => (
const renderActions = (id) => (
<Button
data-testid="view-budget"
onClick={() => navigateToBudgetRedemptions(budgetType)}
onClick={() => navigateToBudgetRedemptions(id)}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] I would recommend treating this Button as a browser hyperlink with the react-router's Link component rather than relying on a JavaScript-only history.push. Users might want to open the budget details page in a new tab (e.g., right click and "Open in new tab", or Cmd + click on the button).

<Button
  data-testid="view-budget"
  as={Link}
  to={`/${enterpriseSlug}/admin/${ROUTE_NAMES.learnerCredit}/${id}`}
>
  View budget
</Button>

>
View Budget
</Button>
);

const renderCardHeader = (budgetType) => {
const renderCardHeader = (budgetType, id) => {
const subtitle = (
<div className="d-flex flex-wrap align-items-center">
<span data-testid="offer-date">
Expand All @@ -73,7 +60,7 @@
subtitle={subtitle}
actions={(
<div>
{renderActions(budgetType)}
{renderActions(id)}
</div>
)}
/>
Expand Down Expand Up @@ -112,42 +99,22 @@

return (
<Stack>
<Row className="m-3">
<Col xs="12">
<Breadcrumb
ariaLabel="Breadcrumb"
links={links}
activeLabel={activeLabel}
/>
</Col>
</Row>
{!detailPage
? (
<>
{renderCardAggregate()}
<h2>Budgets</h2>
<Card
orientation="horizontal"
>
<Card.Body>
<Stack gap={4}>
{renderCardHeader('Overview')}
{renderCardSection(offerSummary?.remainingFunds, offerSummary?.redeemedFunds)}
</Stack>
</Card.Body>
</Card>
</>
)
: (
<LearnerCreditAllocationTable
isLoading={isLoadingOfferRedemptions}
tableData={offerRedemptions}
fetchTableData={fetchOfferRedemptions}
enterpriseUUID={enterpriseUUID}
enterpriseSlug={enterpriseSlug}
enableLearnerPortal={enableLearnerPortal}
/>
)}

<>
{renderCardAggregate()}
<h2>Budgets</h2>
<Card
orientation="horizontal"
>
<Card.Body>
<Stack gap={4}>
{/*{renderCardHeader('Overview', 'f25682b2-62ce-42a7-b596-8acc86811cc9')}*/}

Check failure on line 111 in src/components/learner-credit-management/BudgetCard-V2.jsx

View workflow job for this annotation

GitHub Actions / tests

Expected exception block, space or tab after '/*' in comment

Check failure on line 111 in src/components/learner-credit-management/BudgetCard-V2.jsx

View workflow job for this annotation

GitHub Actions / tests

Expected space or tab before '*/' in comment
{renderCardHeader('Overview', '256829')}
{renderCardSection(offerSummary?.remainingFunds, offerSummary?.redeemedFunds)}
</Stack>
</Card.Body>
</Card>
</>
</Stack>
);
};
Expand All @@ -161,7 +128,7 @@
}).isRequired,
enterpriseUUID: PropTypes.string.isRequired,
enterpriseSlug: PropTypes.string.isRequired,
enableLearnerPortal: PropTypes.bool.isRequired,

Check failure on line 131 in src/components/learner-credit-management/BudgetCard-V2.jsx

View workflow job for this annotation

GitHub Actions / tests

'enableLearnerPortal' PropType is defined but prop is never used
};

export default BudgetCard;
78 changes: 78 additions & 0 deletions src/components/learner-credit-management/BudgetDetailPage.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import {
Row,
Col,
Breadcrumb,
} from '@edx/paragon';
import { connect } from 'react-redux';
import { Helmet } from 'react-helmet';
import { useParams } from 'react-router-dom';
import Hero from '../Hero';

import LoadingMessage from '../LoadingMessage';
import { EnterpriseSubsidiesContext } from '../EnterpriseSubsidiesContext';

import LearnerCreditAllocationTable from './LearnerCreditAllocationTable';
import { useOfferRedemptions } from './data/hooks';
import { isUUID } from './data/utils';
import { ROUTE_NAMES } from '../EnterpriseApp/data/constants';

const PAGE_TITLE = 'Learner Credit Budget Detail';

const BudgetDetailPage = ({
enterpriseUUID,
enterpriseSlug,
}) => {
const { id } = useParams();
const offerId = isUUID(id) ? null : id;
const budgetId = isUUID(id) ? id : null;

const { isLoading } = useContext(EnterpriseSubsidiesContext);
const {
isLoading: isLoadingOfferRedemptions,
offerRedemptions,
fetchOfferRedemptions,
} = useOfferRedemptions(enterpriseUUID, offerId, budgetId);
if (isLoading) {
return <LoadingMessage className="offers" />;
}
const links = [
{ label: 'Budgets', href: `/${enterpriseSlug}/admin/${ROUTE_NAMES.learnerCredit}` },
];
return (
<>
<Helmet title={PAGE_TITLE} />
<Hero title={PAGE_TITLE} />
<Row className="m-3">
<Col xs="12">
<Breadcrumb
ariaLabel="Breadcrumb"
links={links}
activeLabel="Overview"
/>
</Col>
</Row>
<LearnerCreditAllocationTable
isLoading={isLoadingOfferRedemptions}
tableData={offerRedemptions}
fetchTableData={fetchOfferRedemptions}
enterpriseUUID={enterpriseUUID}
enterpriseSlug={enterpriseSlug}
enableLearnerPortal
/>
</>
);
};

const mapStateToProps = state => ({
enterpriseUUID: state.portalConfiguration.enterpriseId,
enterpriseSlug: state.portalConfiguration.enterpriseSlug,
});

BudgetDetailPage.propTypes = {
enterpriseUUID: PropTypes.string.isRequired,
enterpriseSlug: PropTypes.string.isRequired,
};

export default connect(mapStateToProps)(BudgetDetailPage);
13 changes: 9 additions & 4 deletions src/components/learner-credit-management/data/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@
}
};

export const useOfferRedemptions = (enterpriseUUID, offerId) => {
export const useOfferRedemptions = (enterpriseUUID, offerId= null, budgetId = null) => {

Check failure on line 77 in src/components/learner-credit-management/data/hooks.js

View workflow job for this annotation

GitHub Actions / tests

Operator '=' must be spaced
const shouldTrackFetchEvents = useRef(false);
const [isLoading, setIsLoading] = useState(true);
const [offerRedemptions, setOfferRedemptions] = useState({
Expand All @@ -90,9 +90,14 @@
const options = {
page: args.pageIndex + 1, // `DataTable` uses zero-indexed array
pageSize: args.pageSize,
offerId,
ignoreNullCourseListPrice: true,
};
if (budgetId !== null) {
options.budgetId = budgetId;
}
if (offerId !== null) {
options.offerId = offerId;
}
if (args.sortBy?.length > 0) {
applySortByToOptions(args.sortBy, options);
}
Expand Down Expand Up @@ -129,10 +134,10 @@
setIsLoading(false);
}
};
if (offerId) {
if (offerId || budgetId) {
fetch();
}
}, [enterpriseUUID, offerId, shouldTrackFetchEvents]);
}, [enterpriseUUID, offerId, budgetId, shouldTrackFetchEvents]);

const debouncedFetchOfferRedemptions = useMemo(() => debounce(fetchOfferRedemptions, 300), [fetchOfferRedemptions]);

Expand Down
5 changes: 5 additions & 0 deletions src/components/learner-credit-management/data/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,8 @@ export const getProgressBarVariant = ({ percentUtilized, remainingFunds }) => {
}
return variant;
};

// Utility function to check if the ID is a UUID
export const isUUID = (id) => {
return /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(id);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/* 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 {
screen,
render,
waitFor,
} from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';

import { IntlProvider } from '@edx/frontend-platform/i18n';
import BudgetDetailPage from '../BudgetDetailPage';
import { useOfferSummary, useOfferRedemptions } from '../data/hooks';
import { EXEC_ED_OFFER_TYPE } from '../data/constants';
import { MemoryRouter } from 'react-router-dom';

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,
enterpriseSlug: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,
offerType: EXEC_ED_OFFER_TYPE,
};

const BudgetDetailPageWrapper = ({ ...rest }) => (
<MemoryRouter initialEntries={['/test-enterprise/admin/learner-credit/1234']}>

<Provider store={store}>
<IntlProvider locale="en">
<BudgetDetailPage {...rest} />
</IntlProvider>
</Provider>
</MemoryRouter>
);

describe('<BudgetDetailPage />', () => {
describe('with enterprise offer', () => {
beforeEach(() => {
jest.clearAllMocks();
});

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(<BudgetDetailPageWrapper
enterpriseUUID={enterpriseUUID} enterpriseSlug={enterpriseId}
offer={mockOffer}
/>);
expect(screen.getByText('Learner Credit Budget Detail'));
expect(screen.getByText('Overview'));
expect(screen.getByText('No results found'));
});
});
});
Loading