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