diff --git a/src/components/Admin/index.jsx b/src/components/Admin/index.jsx
index 88165f9572..3c103b0276 100644
--- a/src/components/Admin/index.jsx
+++ b/src/components/Admin/index.jsx
@@ -392,7 +392,6 @@ class Admin extends React.Component {
insights,
insightsLoading,
} = this.props;
-
const queryParams = new URLSearchParams(search || '');
const queryParamsLength = Array.from(queryParams.entries()).length;
const filtersActive = queryParamsLength !== 0 && !(queryParamsLength === 1 && queryParams.has('ordering'));
diff --git a/src/components/BudgetExpiryAlertAndModal/data/hooks/useExpiry.jsx b/src/components/BudgetExpiryAlertAndModal/data/hooks/useExpiry.jsx
index 6b2430f189..a150d97649 100644
--- a/src/components/BudgetExpiryAlertAndModal/data/hooks/useExpiry.jsx
+++ b/src/components/BudgetExpiryAlertAndModal/data/hooks/useExpiry.jsx
@@ -3,13 +3,14 @@ import {
getEnterpriseBudgetExpiringAlertCookieName,
getEnterpriseBudgetExpiringModalCookieName,
getExpirationMetadata,
- getNonExpiredBudgets,
+ getExpiredAndNonExpiredBudgets,
} from '../utils';
const useExpiry = (enterpriseId, budgets, modalOpen, modalClose, alertOpen, alertClose) => {
const [notification, setNotification] = useState(null);
const [expirationThreshold, setExpirationThreshold] = useState(null);
const [modal, setModal] = useState(null);
+ const [isNonExpiredBudget, setIsNonExpiredBudget] = useState(false);
useEffect(() => {
if (!budgets || budgets.length === 0) {
@@ -20,18 +21,19 @@ const useExpiry = (enterpriseId, budgets, modalOpen, modalClose, alertOpen, aler
// expired and non-expired budgets. In that case, we only want
// to determine the expiry threshold from the set of *non-expired* budgets,
// so that the alert and modal below do not falsely signal.
- let budgetsToConsiderForExpirationMessaging = budgets;
+ let budgetsToConsiderForExpirationMessaging = [];
- const nonExpiredBudgets = getNonExpiredBudgets(budgets);
- const hasNonExpiredBudgets = nonExpiredBudgets.length > 0;
+ const { nonExpiredBudgets, expiredBudgets } = getExpiredAndNonExpiredBudgets(budgets);
- // If the length of all budgets is different from the length of non-expired budgets,
- // then there exists at least one expired budget (note that we already early-returned
- // above if there are zero total budgets).
- const hasExpiredBudgets = budgets.length !== nonExpiredBudgets.length;
+ // Consider the length of each budget
+ const hasNonExpiredBudgets = nonExpiredBudgets.length > 0;
- if (hasNonExpiredBudgets && hasExpiredBudgets) {
+ // If an unexpired budget exists, set budgetsToConsiderForExpirationMessaging to nonExpiredBudgets
+ if (hasNonExpiredBudgets) {
budgetsToConsiderForExpirationMessaging = nonExpiredBudgets;
+ setIsNonExpiredBudget(true);
+ } else {
+ budgetsToConsiderForExpirationMessaging = expiredBudgets;
}
const earliestExpiryBudget = budgetsToConsiderForExpirationMessaging.reduce(
@@ -97,7 +99,7 @@ const useExpiry = (enterpriseId, budgets, modalOpen, modalClose, alertOpen, aler
};
return {
- notification, modal, dismissModal, dismissAlert,
+ notification, modal, dismissModal, dismissAlert, isNonExpiredBudget,
};
};
diff --git a/src/components/BudgetExpiryAlertAndModal/data/hooks/useExpiry.test.jsx b/src/components/BudgetExpiryAlertAndModal/data/hooks/useExpiry.test.jsx
index eeca281568..0dd2481486 100644
--- a/src/components/BudgetExpiryAlertAndModal/data/hooks/useExpiry.test.jsx
+++ b/src/components/BudgetExpiryAlertAndModal/data/hooks/useExpiry.test.jsx
@@ -12,47 +12,66 @@ const modalClose = jest.fn();
const alertOpen = jest.fn();
const alertClose = jest.fn();
+const offsetDays = {
+ 120: dayjs().add(120, 'day'),
+ 90: dayjs().add(90, 'day'),
+ 60: dayjs().add(60, 'day'),
+ 30: dayjs().add(30, 'day'),
+ 10: dayjs().add(10, 'day'),
+ 1: dayjs().subtract(1, 'day'),
+};
+
describe('useExpiry', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it.each([
- (() => {
- const endDate = dayjs().add(120, 'day');
- return { endDate, expected: expiryThresholds[120]({ date: formatDate(endDate.toString()) }) };
- })(),
- (() => {
- const endDate = dayjs().add(90, 'day');
- return { endDate, expected: expiryThresholds[90]({ date: formatDate(endDate.toString()) }) };
- })(),
- (() => {
- const endDate = dayjs().add(60, 'day');
- return { endDate, expected: expiryThresholds[60]({ date: formatDate(endDate.toString()) }) };
- })(),
- (() => {
- const endDate = dayjs().add(30, 'day');
- return { endDate, expected: expiryThresholds[30]({ date: formatDate(endDate.toString()) }) };
- })(),
- (() => {
- const endDate = dayjs().add(10, 'day');
- const today = dayjs().add(1, 'minutes');
- const durationDiff = dayjs.duration(endDate.diff(today));
-
- return {
- endDate,
- expected: expiryThresholds[10]({
- date: formatDate(endDate.toString()),
- days: durationDiff.days(),
- hours: durationDiff.hours(),
- }),
- };
- })(),
- (() => {
- const endDate = dayjs().subtract(1, 'day');
- return { endDate, expected: expiryThresholds[0]({ date: formatDate(endDate.toString()) }) };
- })(),
- ])('displays correct notification and modal when plan is expiring in %s days', ({ endDate, expected }) => {
+ {
+ endDate: offsetDays['120'],
+ expected: expiryThresholds[120]({
+ date: formatDate(offsetDays['120'].toString()),
+ }),
+ isNonExpiredBudget: true,
+ },
+ {
+ endDate: offsetDays['90'],
+ expected: expiryThresholds[90]({
+ date: formatDate(offsetDays['90'].toString()),
+ }),
+ isNonExpiredBudget: true,
+ },
+ {
+ endDate: offsetDays['60'],
+ expected: expiryThresholds[60]({
+ date: formatDate(offsetDays['60'].toString()),
+ }),
+ isNonExpiredBudget: true,
+ },
+ {
+ endDate: offsetDays['30'],
+ expected: expiryThresholds[30]({
+ date: formatDate(offsetDays['30'].toString()),
+ }),
+ isNonExpiredBudget: true,
+ },
+ {
+ endDate: offsetDays['10'],
+ expected: expiryThresholds[10]({
+ date: formatDate(offsetDays['10'].toString()),
+ days: dayjs.duration(offsetDays['10'].diff(dayjs())).days(),
+ hours: dayjs.duration(offsetDays['10'].diff(dayjs())).hours(),
+ }),
+ isNonExpiredBudget: true,
+ },
+ {
+ endDate: offsetDays['1'],
+ expected: expiryThresholds[0]({
+ date: formatDate(offsetDays['1'].toString()),
+ }),
+ isNonExpiredBudget: false,
+ },
+ ])('displays correct notification and modal when plan is expiring in %s days', ({ endDate, expected, isNonExpiredBudget }) => {
const budgets = [{ end: endDate }]; // Mock data with an expiring budget
const { result } = renderHook(() => useExpiry('enterpriseId', budgets, modalOpen, modalClose, alertOpen, alertClose));
@@ -60,6 +79,7 @@ describe('useExpiry', () => {
expect(result.current.notification).toEqual(expected.notificationTemplate);
expect(result.current.modal).toEqual(expected.modalTemplate);
expect(result.current.status).toEqual(expected.status);
+ expect(result.current.isNonExpiredBudget).toEqual(isNonExpiredBudget);
});
it('displays no notification with both an expired and non-expired budget', () => {
diff --git a/src/components/BudgetExpiryAlertAndModal/data/index.test.jsx b/src/components/BudgetExpiryAlertAndModal/data/index.test.jsx
new file mode 100644
index 0000000000..4a0577ac89
--- /dev/null
+++ b/src/components/BudgetExpiryAlertAndModal/data/index.test.jsx
@@ -0,0 +1,90 @@
+import { screen } from '@testing-library/react';
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+import { QueryClientProvider } from '@tanstack/react-query';
+import configureMockStore from 'redux-mock-store';
+import thunk from 'redux-thunk';
+import { Provider } from 'react-redux';
+import { renderWithRouter } from '@edx/frontend-enterprise-utils';
+import { v4 as uuidv4 } from 'uuid';
+import dayjs from 'dayjs';
+import BudgetExpiryAlertAndModal from '../index';
+import { queryClient } from '../../test/testUtils';
+import { useEnterpriseBudgets } from '../../EnterpriseSubsidiesContext/data/hooks';
+
+jest.mock('../../EnterpriseSubsidiesContext/data/hooks', () => ({
+ ...jest.requireActual('../../EnterpriseSubsidiesContext/data/hooks'),
+ useEnterpriseBudgets: jest.fn(),
+}));
+
+const mockStore = configureMockStore([thunk]);
+const getMockStore = store => mockStore(store);
+const enterpriseSlug = 'test-enterprise';
+const enterpriseUUID = '1234';
+const initialStoreState = {
+ portalConfiguration: {
+ enterpriseId: enterpriseUUID,
+ enterpriseSlug,
+ disableExpiryMessagingForLearnerCredit: false,
+ enterpriseFeatures: {
+ topDownAssignmentRealTimeLcm: true,
+ },
+ },
+};
+const mockEnterpriseBudgetUuid = uuidv4();
+const mockEnterpriseBudget = [
+ {
+ source: 'policy',
+ id: mockEnterpriseBudgetUuid,
+ name: 'test expiration plan 2 --- Everything',
+ start: '2024-04-15T00:00:00Z',
+ end: dayjs().add(11, 'days'),
+ isCurrent: true,
+ aggregates: {
+ available: 20000,
+ spent: 0,
+ pending: 0,
+ },
+ isAssignable: true,
+ isRetired: false,
+ },
+];
+
+const mockEndDateText = mockEnterpriseBudget[0].end.format('MMM D, YYYY');
+
+const BudgetExpiryAlertAndModalWrapper = ({
+ initialState = initialStoreState,
+}) => {
+ const store = getMockStore(initialState);
+ return (
+
+
+
+
+
+
+
+ );
+};
+
+describe('BudgetExpiryAlertAndModal', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ useEnterpriseBudgets.mockReturnValue({ data: mockEnterpriseBudget });
+ });
+ it('renders without crashing', () => {
+ renderWithRouter();
+ expect(screen.getByTestId('expiry-notification-alert')).toBeTruthy();
+ expect(screen.getByText(`Your Learner Credit plan expires ${mockEndDateText}.`, { exact: false })).toBeTruthy();
+ });
+ it('does not render when budget is non expired and disableExpiryMessagingForLearnerCredit is true', () => {
+ const updatedInitialStoreState = {
+ portalConfiguration: {
+ ...initialStoreState.portalConfiguration,
+ disableExpiryMessagingForLearnerCredit: true,
+ },
+ };
+ renderWithRouter();
+ expect(screen.queryByTestId('expiry-notification-alert')).toBeFalsy();
+ expect(screen.queryByText(`Your Learner Credit plan expires ${mockEndDateText}.`, { exact: false })).toBeFalsy();
+ });
+});
diff --git a/src/components/BudgetExpiryAlertAndModal/data/utils.js b/src/components/BudgetExpiryAlertAndModal/data/utils.js
index 12a944d450..60805a7f2a 100644
--- a/src/components/BudgetExpiryAlertAndModal/data/utils.js
+++ b/src/components/BudgetExpiryAlertAndModal/data/utils.js
@@ -5,9 +5,21 @@ import ExpiryThresholds from './expiryThresholds';
dayjs.extend(duration);
-export const getNonExpiredBudgets = (budgets) => {
+export const getExpiredAndNonExpiredBudgets = (budgets) => {
const today = dayjs();
- return budgets.filter((budget) => today <= dayjs(budget.end));
+ const nonExpiredBudgets = [];
+ const expiredBudgets = [];
+ budgets.forEach((budget) => {
+ if (today <= dayjs(budget.end)) {
+ nonExpiredBudgets.push(budget);
+ } else {
+ expiredBudgets.push(budget);
+ }
+ });
+ return {
+ nonExpiredBudgets,
+ expiredBudgets,
+ };
};
export const getExpirationMetadata = (endDateStr) => {
diff --git a/src/components/BudgetExpiryAlertAndModal/index.jsx b/src/components/BudgetExpiryAlertAndModal/index.jsx
index 89566847ac..102c3e9969 100644
--- a/src/components/BudgetExpiryAlertAndModal/index.jsx
+++ b/src/components/BudgetExpiryAlertAndModal/index.jsx
@@ -17,10 +17,9 @@ import EVENT_NAMES from '../../eventTracking';
import useExpiry from './data/hooks/useExpiry';
-const BudgetExpiryAlertAndModal = ({ enterpriseUUID, enterpriseFeatures }) => {
+const BudgetExpiryAlertAndModal = ({ enterpriseUUID, enterpriseFeatures, disableExpiryMessagingForLearnerCredit }) => {
const [modalIsOpen, modalOpen, modalClose] = useToggle(false);
const [alertIsOpen, alertOpen, alertClose] = useToggle(false);
-
const location = useLocation();
const budgetDetailRouteMatch = matchPath(
@@ -46,7 +45,7 @@ const BudgetExpiryAlertAndModal = ({ enterpriseUUID, enterpriseFeatures }) => {
});
const {
- notification, modal, dismissModal, dismissAlert,
+ notification, modal, dismissModal, dismissAlert, isNonExpiredBudget,
} = useExpiry(
enterpriseUUID,
budgets,
@@ -64,6 +63,10 @@ const BudgetExpiryAlertAndModal = ({ enterpriseUUID, enterpriseFeatures }) => {
};
}, [modal, notification]);
+ if (isNonExpiredBudget && disableExpiryMessagingForLearnerCredit) {
+ return null;
+ }
+
return (
<>
{notification && (
@@ -141,6 +144,7 @@ const BudgetExpiryAlertAndModal = ({ enterpriseUUID, enterpriseFeatures }) => {
const mapStateToProps = state => ({
enterpriseUUID: state.portalConfiguration.enterpriseId,
enterpriseFeatures: state.portalConfiguration.enterpriseFeatures,
+ disableExpiryMessagingForLearnerCredit: state.portalConfiguration.disableExpiryMessagingForLearnerCredit,
});
BudgetExpiryAlertAndModal.propTypes = {
@@ -148,6 +152,7 @@ BudgetExpiryAlertAndModal.propTypes = {
enterpriseFeatures: PropTypes.shape({
topDownAssignmentRealTimeLcm: PropTypes.bool.isRequired,
}),
+ disableExpiryMessagingForLearnerCredit: PropTypes.bool.isRequired,
};
export default connect(mapStateToProps)(BudgetExpiryAlertAndModal);
diff --git a/src/components/ContentHighlights/tests/ContentHighlights.test.jsx b/src/components/ContentHighlights/tests/ContentHighlights.test.jsx
index 75483d3fd3..2b2118f826 100644
--- a/src/components/ContentHighlights/tests/ContentHighlights.test.jsx
+++ b/src/components/ContentHighlights/tests/ContentHighlights.test.jsx
@@ -89,6 +89,5 @@ describe('', () => {
data: { results: [{ applies_to_all_contexts: true }] },
}));
renderWithRouter();
- screen.debug();
});
});
diff --git a/src/containers/EnterpriseApp/index.jsx b/src/containers/EnterpriseApp/index.jsx
index 2e7e49cc8f..cc5d3ffed6 100644
--- a/src/containers/EnterpriseApp/index.jsx
+++ b/src/containers/EnterpriseApp/index.jsx
@@ -7,12 +7,12 @@ import { toggleSidebarToggle } from '../../data/actions/sidebar';
const mapStateToProps = (state) => {
const enterpriseListState = state.table['enterprise-list'] || {};
-
return {
enterprises: enterpriseListState.data,
error: state.portalConfiguration.error,
+ disableExpiryMessagingForLearnerCredit: state.portalConfiguration.disableExpiryMessagingForLearnerCredit,
enableCodeManagementScreen: state.portalConfiguration.enableCodeManagementScreen,
- enableSubscriptionManagementScreen: state.portalConfiguration.enableSubscriptionManagementScreen, // eslint-disable-line max-len
+ enableSubscriptionManagementScreen: state.portalConfiguration.enableSubscriptionManagementScreen,
enableSamlConfigurationScreen: state.portalConfiguration.enableSamlConfigurationScreen,
enableAnalyticsScreen: state.portalConfiguration.enableAnalyticsScreen,
enableLearnerPortal: state.portalConfiguration.enableLearnerPortal,
diff --git a/src/data/reducers/portalConfiguration.js b/src/data/reducers/portalConfiguration.js
index 3c7a19435e..abc5b554b0 100644
--- a/src/data/reducers/portalConfiguration.js
+++ b/src/data/reducers/portalConfiguration.js
@@ -15,6 +15,7 @@ const initialState = {
enterpriseSlug: null,
enterpriseBranding: null,
identityProvider: null,
+ disableExpiryMessagingForLearnerCredit: false,
enableCodeManagementScreen: false,
enableReportingConfigScreen: false,
enableSubscriptionManagementScreen: false,
@@ -50,6 +51,7 @@ const portalConfiguration = (state = initialState, action) => {
enterpriseSlug: action.payload.data.slug,
enterpriseBranding: action.payload.data.branding_configuration,
identityProvider: action.payload.data.identity_provider,
+ disableExpiryMessagingForLearnerCredit: action.payload.data.disable_expiry_messaging_for_learner_credit,
enableCodeManagementScreen: action.payload.data.enable_portal_code_management_screen,
enableReportingConfigScreen: action.payload.data.enable_portal_reporting_config_screen,
enableSubscriptionManagementScreen: action.payload.data.enable_portal_subscription_management_screen, // eslint-disable-line max-len
@@ -76,6 +78,7 @@ const portalConfiguration = (state = initialState, action) => {
enterpriseSlug: null,
enterpriseBranding: null,
identityProvider: null,
+ disableExpiryMessagingForLearnerCredit: false,
enableCodeManagementScreen: false,
enableReportingConfigScreen: false,
enableSubscriptionManagementScreen: false,
@@ -100,6 +103,7 @@ const portalConfiguration = (state = initialState, action) => {
enterpriseSlug: null,
enterpriseBranding: null,
identityProvider: null,
+ disableExpiryMessagingForLearnerCredit: false,
enableCodeManagementScreen: false,
enableReportingConfigScreen: false,
enableSubscriptionManagementScreen: false,
diff --git a/src/data/reducers/portalConfiguration.test.js b/src/data/reducers/portalConfiguration.test.js
index 897d1212eb..5baf895707 100644
--- a/src/data/reducers/portalConfiguration.test.js
+++ b/src/data/reducers/portalConfiguration.test.js
@@ -14,6 +14,7 @@ const initialState = {
enterpriseSlug: null,
enterpriseBranding: null,
identityProvider: null,
+ disableExpiryMessagingForLearnerCredit: false,
enableCodeManagementScreen: false,
enableReportingConfigScreen: false,
enableSubscriptionManagementScreen: false,
@@ -43,6 +44,7 @@ const enterpriseData = {
identity_provider: {
uuid: 'test-identity-provider-uuid',
},
+ disable_expiry_messaging_for_learner_credit: true,
enable_portal_code_management_screen: true,
enable_portal_reporting_config_screen: true,
enable_portal_subscription_management_screen: true,
@@ -77,6 +79,7 @@ describe('portalConfiguration reducer', () => {
enterpriseSlug: enterpriseData.slug,
enterpriseBranding: enterpriseData.branding_configuration,
identityProvider: enterpriseData.identity_provider,
+ disableExpiryMessagingForLearnerCredit: enterpriseData.disable_expiry_messaging_for_learner_credit,
enableCodeManagementScreen: enterpriseData.enable_portal_code_management_screen,
enableReportingConfigScreen: enterpriseData.enable_portal_reporting_config_screen,
enableSubscriptionManagementScreen: enterpriseData.enable_portal_subscription_management_screen, // eslint-disable-line max-len