Skip to content

Commit 9f442ff

Browse files
authored
feat: Conditionally render expiring learner credit alerts and modals (#1224)
* feat: Conditionally render expiring learner credit alerts and modals * chore: tests * chore: more tests * chore: more tests * chore: PR feedback
1 parent 8dd28c1 commit 9f442ff

File tree

10 files changed

+188
-54
lines changed

10 files changed

+188
-54
lines changed

src/components/Admin/index.jsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -392,7 +392,6 @@ class Admin extends React.Component {
392392
insights,
393393
insightsLoading,
394394
} = this.props;
395-
396395
const queryParams = new URLSearchParams(search || '');
397396
const queryParamsLength = Array.from(queryParams.entries()).length;
398397
const filtersActive = queryParamsLength !== 0 && !(queryParamsLength === 1 && queryParams.has('ordering'));

src/components/BudgetExpiryAlertAndModal/data/hooks/useExpiry.jsx

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@ import {
33
getEnterpriseBudgetExpiringAlertCookieName,
44
getEnterpriseBudgetExpiringModalCookieName,
55
getExpirationMetadata,
6-
getNonExpiredBudgets,
6+
getExpiredAndNonExpiredBudgets,
77
} from '../utils';
88

99
const useExpiry = (enterpriseId, budgets, modalOpen, modalClose, alertOpen, alertClose) => {
1010
const [notification, setNotification] = useState(null);
1111
const [expirationThreshold, setExpirationThreshold] = useState(null);
1212
const [modal, setModal] = useState(null);
13+
const [isNonExpiredBudget, setIsNonExpiredBudget] = useState(false);
1314

1415
useEffect(() => {
1516
if (!budgets || budgets.length === 0) {
@@ -20,18 +21,19 @@ const useExpiry = (enterpriseId, budgets, modalOpen, modalClose, alertOpen, aler
2021
// expired and non-expired budgets. In that case, we only want
2122
// to determine the expiry threshold from the set of *non-expired* budgets,
2223
// so that the alert and modal below do not falsely signal.
23-
let budgetsToConsiderForExpirationMessaging = budgets;
24+
let budgetsToConsiderForExpirationMessaging = [];
2425

25-
const nonExpiredBudgets = getNonExpiredBudgets(budgets);
26-
const hasNonExpiredBudgets = nonExpiredBudgets.length > 0;
26+
const { nonExpiredBudgets, expiredBudgets } = getExpiredAndNonExpiredBudgets(budgets);
2727

28-
// If the length of all budgets is different from the length of non-expired budgets,
29-
// then there exists at least one expired budget (note that we already early-returned
30-
// above if there are zero total budgets).
31-
const hasExpiredBudgets = budgets.length !== nonExpiredBudgets.length;
28+
// Consider the length of each budget
29+
const hasNonExpiredBudgets = nonExpiredBudgets.length > 0;
3230

33-
if (hasNonExpiredBudgets && hasExpiredBudgets) {
31+
// If an unexpired budget exists, set budgetsToConsiderForExpirationMessaging to nonExpiredBudgets
32+
if (hasNonExpiredBudgets) {
3433
budgetsToConsiderForExpirationMessaging = nonExpiredBudgets;
34+
setIsNonExpiredBudget(true);
35+
} else {
36+
budgetsToConsiderForExpirationMessaging = expiredBudgets;
3537
}
3638

3739
const earliestExpiryBudget = budgetsToConsiderForExpirationMessaging.reduce(
@@ -97,7 +99,7 @@ const useExpiry = (enterpriseId, budgets, modalOpen, modalClose, alertOpen, aler
9799
};
98100

99101
return {
100-
notification, modal, dismissModal, dismissAlert,
102+
notification, modal, dismissModal, dismissAlert, isNonExpiredBudget,
101103
};
102104
};
103105

src/components/BudgetExpiryAlertAndModal/data/hooks/useExpiry.test.jsx

Lines changed: 55 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -12,54 +12,74 @@ const modalClose = jest.fn();
1212
const alertOpen = jest.fn();
1313
const alertClose = jest.fn();
1414

15+
const offsetDays = {
16+
120: dayjs().add(120, 'day'),
17+
90: dayjs().add(90, 'day'),
18+
60: dayjs().add(60, 'day'),
19+
30: dayjs().add(30, 'day'),
20+
10: dayjs().add(10, 'day'),
21+
1: dayjs().subtract(1, 'day'),
22+
};
23+
1524
describe('useExpiry', () => {
1625
beforeEach(() => {
1726
jest.clearAllMocks();
1827
});
1928

2029
it.each([
21-
(() => {
22-
const endDate = dayjs().add(120, 'day');
23-
return { endDate, expected: expiryThresholds[120]({ date: formatDate(endDate.toString()) }) };
24-
})(),
25-
(() => {
26-
const endDate = dayjs().add(90, 'day');
27-
return { endDate, expected: expiryThresholds[90]({ date: formatDate(endDate.toString()) }) };
28-
})(),
29-
(() => {
30-
const endDate = dayjs().add(60, 'day');
31-
return { endDate, expected: expiryThresholds[60]({ date: formatDate(endDate.toString()) }) };
32-
})(),
33-
(() => {
34-
const endDate = dayjs().add(30, 'day');
35-
return { endDate, expected: expiryThresholds[30]({ date: formatDate(endDate.toString()) }) };
36-
})(),
37-
(() => {
38-
const endDate = dayjs().add(10, 'day');
39-
const today = dayjs().add(1, 'minutes');
40-
const durationDiff = dayjs.duration(endDate.diff(today));
41-
42-
return {
43-
endDate,
44-
expected: expiryThresholds[10]({
45-
date: formatDate(endDate.toString()),
46-
days: durationDiff.days(),
47-
hours: durationDiff.hours(),
48-
}),
49-
};
50-
})(),
51-
(() => {
52-
const endDate = dayjs().subtract(1, 'day');
53-
return { endDate, expected: expiryThresholds[0]({ date: formatDate(endDate.toString()) }) };
54-
})(),
55-
])('displays correct notification and modal when plan is expiring in %s days', ({ endDate, expected }) => {
30+
{
31+
endDate: offsetDays['120'],
32+
expected: expiryThresholds[120]({
33+
date: formatDate(offsetDays['120'].toString()),
34+
}),
35+
isNonExpiredBudget: true,
36+
},
37+
{
38+
endDate: offsetDays['90'],
39+
expected: expiryThresholds[90]({
40+
date: formatDate(offsetDays['90'].toString()),
41+
}),
42+
isNonExpiredBudget: true,
43+
},
44+
{
45+
endDate: offsetDays['60'],
46+
expected: expiryThresholds[60]({
47+
date: formatDate(offsetDays['60'].toString()),
48+
}),
49+
isNonExpiredBudget: true,
50+
},
51+
{
52+
endDate: offsetDays['30'],
53+
expected: expiryThresholds[30]({
54+
date: formatDate(offsetDays['30'].toString()),
55+
}),
56+
isNonExpiredBudget: true,
57+
},
58+
{
59+
endDate: offsetDays['10'],
60+
expected: expiryThresholds[10]({
61+
date: formatDate(offsetDays['10'].toString()),
62+
days: dayjs.duration(offsetDays['10'].diff(dayjs())).days(),
63+
hours: dayjs.duration(offsetDays['10'].diff(dayjs())).hours(),
64+
}),
65+
isNonExpiredBudget: true,
66+
},
67+
{
68+
endDate: offsetDays['1'],
69+
expected: expiryThresholds[0]({
70+
date: formatDate(offsetDays['1'].toString()),
71+
}),
72+
isNonExpiredBudget: false,
73+
},
74+
])('displays correct notification and modal when plan is expiring in %s days', ({ endDate, expected, isNonExpiredBudget }) => {
5675
const budgets = [{ end: endDate }]; // Mock data with an expiring budget
5776

5877
const { result } = renderHook(() => useExpiry('enterpriseId', budgets, modalOpen, modalClose, alertOpen, alertClose));
5978

6079
expect(result.current.notification).toEqual(expected.notificationTemplate);
6180
expect(result.current.modal).toEqual(expected.modalTemplate);
6281
expect(result.current.status).toEqual(expected.status);
82+
expect(result.current.isNonExpiredBudget).toEqual(isNonExpiredBudget);
6383
});
6484

6585
it('displays no notification with both an expired and non-expired budget', () => {
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { screen } from '@testing-library/react';
2+
import { IntlProvider } from '@edx/frontend-platform/i18n';
3+
import { QueryClientProvider } from '@tanstack/react-query';
4+
import configureMockStore from 'redux-mock-store';
5+
import thunk from 'redux-thunk';
6+
import { Provider } from 'react-redux';
7+
import { renderWithRouter } from '@edx/frontend-enterprise-utils';
8+
import { v4 as uuidv4 } from 'uuid';
9+
import dayjs from 'dayjs';
10+
import BudgetExpiryAlertAndModal from '../index';
11+
import { queryClient } from '../../test/testUtils';
12+
import { useEnterpriseBudgets } from '../../EnterpriseSubsidiesContext/data/hooks';
13+
14+
jest.mock('../../EnterpriseSubsidiesContext/data/hooks', () => ({
15+
...jest.requireActual('../../EnterpriseSubsidiesContext/data/hooks'),
16+
useEnterpriseBudgets: jest.fn(),
17+
}));
18+
19+
const mockStore = configureMockStore([thunk]);
20+
const getMockStore = store => mockStore(store);
21+
const enterpriseSlug = 'test-enterprise';
22+
const enterpriseUUID = '1234';
23+
const initialStoreState = {
24+
portalConfiguration: {
25+
enterpriseId: enterpriseUUID,
26+
enterpriseSlug,
27+
disableExpiryMessagingForLearnerCredit: false,
28+
enterpriseFeatures: {
29+
topDownAssignmentRealTimeLcm: true,
30+
},
31+
},
32+
};
33+
const mockEnterpriseBudgetUuid = uuidv4();
34+
const mockEnterpriseBudget = [
35+
{
36+
source: 'policy',
37+
id: mockEnterpriseBudgetUuid,
38+
name: 'test expiration plan 2 --- Everything',
39+
start: '2024-04-15T00:00:00Z',
40+
end: dayjs().add(11, 'days'),
41+
isCurrent: true,
42+
aggregates: {
43+
available: 20000,
44+
spent: 0,
45+
pending: 0,
46+
},
47+
isAssignable: true,
48+
isRetired: false,
49+
},
50+
];
51+
52+
const mockEndDateText = mockEnterpriseBudget[0].end.format('MMM D, YYYY');
53+
54+
const BudgetExpiryAlertAndModalWrapper = ({
55+
initialState = initialStoreState,
56+
}) => {
57+
const store = getMockStore(initialState);
58+
return (
59+
<QueryClientProvider client={queryClient()}>
60+
<IntlProvider locale="en">
61+
<Provider store={store}>
62+
<BudgetExpiryAlertAndModal />
63+
</Provider>
64+
</IntlProvider>
65+
</QueryClientProvider>
66+
);
67+
};
68+
69+
describe('BudgetExpiryAlertAndModal', () => {
70+
beforeEach(() => {
71+
jest.clearAllMocks();
72+
useEnterpriseBudgets.mockReturnValue({ data: mockEnterpriseBudget });
73+
});
74+
it('renders without crashing', () => {
75+
renderWithRouter(<BudgetExpiryAlertAndModalWrapper />);
76+
expect(screen.getByTestId('expiry-notification-alert')).toBeTruthy();
77+
expect(screen.getByText(`Your Learner Credit plan expires ${mockEndDateText}.`, { exact: false })).toBeTruthy();
78+
});
79+
it('does not render when budget is non expired and disableExpiryMessagingForLearnerCredit is true', () => {
80+
const updatedInitialStoreState = {
81+
portalConfiguration: {
82+
...initialStoreState.portalConfiguration,
83+
disableExpiryMessagingForLearnerCredit: true,
84+
},
85+
};
86+
renderWithRouter(<BudgetExpiryAlertAndModalWrapper initialState={updatedInitialStoreState} />);
87+
expect(screen.queryByTestId('expiry-notification-alert')).toBeFalsy();
88+
expect(screen.queryByText(`Your Learner Credit plan expires ${mockEndDateText}.`, { exact: false })).toBeFalsy();
89+
});
90+
});

src/components/BudgetExpiryAlertAndModal/data/utils.js

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,21 @@ import ExpiryThresholds from './expiryThresholds';
55

66
dayjs.extend(duration);
77

8-
export const getNonExpiredBudgets = (budgets) => {
8+
export const getExpiredAndNonExpiredBudgets = (budgets) => {
99
const today = dayjs();
10-
return budgets.filter((budget) => today <= dayjs(budget.end));
10+
const nonExpiredBudgets = [];
11+
const expiredBudgets = [];
12+
budgets.forEach((budget) => {
13+
if (today <= dayjs(budget.end)) {
14+
nonExpiredBudgets.push(budget);
15+
} else {
16+
expiredBudgets.push(budget);
17+
}
18+
});
19+
return {
20+
nonExpiredBudgets,
21+
expiredBudgets,
22+
};
1123
};
1224

1325
export const getExpirationMetadata = (endDateStr) => {

src/components/BudgetExpiryAlertAndModal/index.jsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,9 @@ import EVENT_NAMES from '../../eventTracking';
1717

1818
import useExpiry from './data/hooks/useExpiry';
1919

20-
const BudgetExpiryAlertAndModal = ({ enterpriseUUID, enterpriseFeatures }) => {
20+
const BudgetExpiryAlertAndModal = ({ enterpriseUUID, enterpriseFeatures, disableExpiryMessagingForLearnerCredit }) => {
2121
const [modalIsOpen, modalOpen, modalClose] = useToggle(false);
2222
const [alertIsOpen, alertOpen, alertClose] = useToggle(false);
23-
2423
const location = useLocation();
2524

2625
const budgetDetailRouteMatch = matchPath(
@@ -46,7 +45,7 @@ const BudgetExpiryAlertAndModal = ({ enterpriseUUID, enterpriseFeatures }) => {
4645
});
4746

4847
const {
49-
notification, modal, dismissModal, dismissAlert,
48+
notification, modal, dismissModal, dismissAlert, isNonExpiredBudget,
5049
} = useExpiry(
5150
enterpriseUUID,
5251
budgets,
@@ -64,6 +63,10 @@ const BudgetExpiryAlertAndModal = ({ enterpriseUUID, enterpriseFeatures }) => {
6463
};
6564
}, [modal, notification]);
6665

66+
if (isNonExpiredBudget && disableExpiryMessagingForLearnerCredit) {
67+
return null;
68+
}
69+
6770
return (
6871
<>
6972
{notification && (
@@ -141,13 +144,15 @@ const BudgetExpiryAlertAndModal = ({ enterpriseUUID, enterpriseFeatures }) => {
141144
const mapStateToProps = state => ({
142145
enterpriseUUID: state.portalConfiguration.enterpriseId,
143146
enterpriseFeatures: state.portalConfiguration.enterpriseFeatures,
147+
disableExpiryMessagingForLearnerCredit: state.portalConfiguration.disableExpiryMessagingForLearnerCredit,
144148
});
145149

146150
BudgetExpiryAlertAndModal.propTypes = {
147151
enterpriseUUID: PropTypes.string.isRequired,
148152
enterpriseFeatures: PropTypes.shape({
149153
topDownAssignmentRealTimeLcm: PropTypes.bool.isRequired,
150154
}),
155+
disableExpiryMessagingForLearnerCredit: PropTypes.bool.isRequired,
151156
};
152157

153158
export default connect(mapStateToProps)(BudgetExpiryAlertAndModal);

src/components/ContentHighlights/tests/ContentHighlights.test.jsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,5 @@ describe('<ContentHighlights>', () => {
8989
data: { results: [{ applies_to_all_contexts: true }] },
9090
}));
9191
renderWithRouter(<ContentHighlightsWrapper location={{ state: {} }} />);
92-
screen.debug();
9392
});
9493
});

src/containers/EnterpriseApp/index.jsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,12 @@ import { toggleSidebarToggle } from '../../data/actions/sidebar';
77

88
const mapStateToProps = (state) => {
99
const enterpriseListState = state.table['enterprise-list'] || {};
10-
1110
return {
1211
enterprises: enterpriseListState.data,
1312
error: state.portalConfiguration.error,
13+
disableExpiryMessagingForLearnerCredit: state.portalConfiguration.disableExpiryMessagingForLearnerCredit,
1414
enableCodeManagementScreen: state.portalConfiguration.enableCodeManagementScreen,
15-
enableSubscriptionManagementScreen: state.portalConfiguration.enableSubscriptionManagementScreen, // eslint-disable-line max-len
15+
enableSubscriptionManagementScreen: state.portalConfiguration.enableSubscriptionManagementScreen,
1616
enableSamlConfigurationScreen: state.portalConfiguration.enableSamlConfigurationScreen,
1717
enableAnalyticsScreen: state.portalConfiguration.enableAnalyticsScreen,
1818
enableLearnerPortal: state.portalConfiguration.enableLearnerPortal,

src/data/reducers/portalConfiguration.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const initialState = {
1515
enterpriseSlug: null,
1616
enterpriseBranding: null,
1717
identityProvider: null,
18+
disableExpiryMessagingForLearnerCredit: false,
1819
enableCodeManagementScreen: false,
1920
enableReportingConfigScreen: false,
2021
enableSubscriptionManagementScreen: false,
@@ -50,6 +51,7 @@ const portalConfiguration = (state = initialState, action) => {
5051
enterpriseSlug: action.payload.data.slug,
5152
enterpriseBranding: action.payload.data.branding_configuration,
5253
identityProvider: action.payload.data.identity_provider,
54+
disableExpiryMessagingForLearnerCredit: action.payload.data.disable_expiry_messaging_for_learner_credit,
5355
enableCodeManagementScreen: action.payload.data.enable_portal_code_management_screen,
5456
enableReportingConfigScreen: action.payload.data.enable_portal_reporting_config_screen,
5557
enableSubscriptionManagementScreen: action.payload.data.enable_portal_subscription_management_screen, // eslint-disable-line max-len
@@ -76,6 +78,7 @@ const portalConfiguration = (state = initialState, action) => {
7678
enterpriseSlug: null,
7779
enterpriseBranding: null,
7880
identityProvider: null,
81+
disableExpiryMessagingForLearnerCredit: false,
7982
enableCodeManagementScreen: false,
8083
enableReportingConfigScreen: false,
8184
enableSubscriptionManagementScreen: false,
@@ -100,6 +103,7 @@ const portalConfiguration = (state = initialState, action) => {
100103
enterpriseSlug: null,
101104
enterpriseBranding: null,
102105
identityProvider: null,
106+
disableExpiryMessagingForLearnerCredit: false,
103107
enableCodeManagementScreen: false,
104108
enableReportingConfigScreen: false,
105109
enableSubscriptionManagementScreen: false,

0 commit comments

Comments
 (0)