Skip to content

Commit

Permalink
feat: Conditionally render expiring learner credit alerts and modals (#…
Browse files Browse the repository at this point in the history
…1224)

* feat: Conditionally render expiring learner credit alerts and modals

* chore: tests

* chore: more tests

* chore: more tests

* chore: PR feedback
  • Loading branch information
brobro10000 authored May 22, 2024
1 parent 8dd28c1 commit 9f442ff
Show file tree
Hide file tree
Showing 10 changed files with 188 additions and 54 deletions.
1 change: 0 additions & 1 deletion src/components/Admin/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand Down
22 changes: 12 additions & 10 deletions src/components/BudgetExpiryAlertAndModal/data/hooks/useExpiry.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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(
Expand Down Expand Up @@ -97,7 +99,7 @@ const useExpiry = (enterpriseId, budgets, modalOpen, modalClose, alertOpen, aler
};

return {
notification, modal, dismissModal, dismissAlert,
notification, modal, dismissModal, dismissAlert, isNonExpiredBudget,
};
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,54 +12,74 @@ 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));

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', () => {
Expand Down
90 changes: 90 additions & 0 deletions src/components/BudgetExpiryAlertAndModal/data/index.test.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<QueryClientProvider client={queryClient()}>
<IntlProvider locale="en">
<Provider store={store}>
<BudgetExpiryAlertAndModal />
</Provider>
</IntlProvider>
</QueryClientProvider>
);
};

describe('BudgetExpiryAlertAndModal', () => {
beforeEach(() => {
jest.clearAllMocks();
useEnterpriseBudgets.mockReturnValue({ data: mockEnterpriseBudget });
});
it('renders without crashing', () => {
renderWithRouter(<BudgetExpiryAlertAndModalWrapper />);
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(<BudgetExpiryAlertAndModalWrapper initialState={updatedInitialStoreState} />);
expect(screen.queryByTestId('expiry-notification-alert')).toBeFalsy();
expect(screen.queryByText(`Your Learner Credit plan expires ${mockEndDateText}.`, { exact: false })).toBeFalsy();
});
});
16 changes: 14 additions & 2 deletions src/components/BudgetExpiryAlertAndModal/data/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
11 changes: 8 additions & 3 deletions src/components/BudgetExpiryAlertAndModal/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -46,7 +45,7 @@ const BudgetExpiryAlertAndModal = ({ enterpriseUUID, enterpriseFeatures }) => {
});

const {
notification, modal, dismissModal, dismissAlert,
notification, modal, dismissModal, dismissAlert, isNonExpiredBudget,
} = useExpiry(
enterpriseUUID,
budgets,
Expand All @@ -64,6 +63,10 @@ const BudgetExpiryAlertAndModal = ({ enterpriseUUID, enterpriseFeatures }) => {
};
}, [modal, notification]);

if (isNonExpiredBudget && disableExpiryMessagingForLearnerCredit) {
return null;
}

return (
<>
{notification && (
Expand Down Expand Up @@ -141,13 +144,15 @@ const BudgetExpiryAlertAndModal = ({ enterpriseUUID, enterpriseFeatures }) => {
const mapStateToProps = state => ({
enterpriseUUID: state.portalConfiguration.enterpriseId,
enterpriseFeatures: state.portalConfiguration.enterpriseFeatures,
disableExpiryMessagingForLearnerCredit: state.portalConfiguration.disableExpiryMessagingForLearnerCredit,
});

BudgetExpiryAlertAndModal.propTypes = {
enterpriseUUID: PropTypes.string.isRequired,
enterpriseFeatures: PropTypes.shape({
topDownAssignmentRealTimeLcm: PropTypes.bool.isRequired,
}),
disableExpiryMessagingForLearnerCredit: PropTypes.bool.isRequired,
};

export default connect(mapStateToProps)(BudgetExpiryAlertAndModal);
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,5 @@ describe('<ContentHighlights>', () => {
data: { results: [{ applies_to_all_contexts: true }] },
}));
renderWithRouter(<ContentHighlightsWrapper location={{ state: {} }} />);
screen.debug();
});
});
4 changes: 2 additions & 2 deletions src/containers/EnterpriseApp/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions src/data/reducers/portalConfiguration.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const initialState = {
enterpriseSlug: null,
enterpriseBranding: null,
identityProvider: null,
disableExpiryMessagingForLearnerCredit: false,
enableCodeManagementScreen: false,
enableReportingConfigScreen: false,
enableSubscriptionManagementScreen: false,
Expand Down Expand Up @@ -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
Expand All @@ -76,6 +78,7 @@ const portalConfiguration = (state = initialState, action) => {
enterpriseSlug: null,
enterpriseBranding: null,
identityProvider: null,
disableExpiryMessagingForLearnerCredit: false,
enableCodeManagementScreen: false,
enableReportingConfigScreen: false,
enableSubscriptionManagementScreen: false,
Expand All @@ -100,6 +103,7 @@ const portalConfiguration = (state = initialState, action) => {
enterpriseSlug: null,
enterpriseBranding: null,
identityProvider: null,
disableExpiryMessagingForLearnerCredit: false,
enableCodeManagementScreen: false,
enableReportingConfigScreen: false,
enableSubscriptionManagementScreen: false,
Expand Down
Loading

0 comments on commit 9f442ff

Please sign in to comment.